Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion deploy/tests/e2e/cookie-persistence/config/deploy.yml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apps/v1
metadata:
name: http-echo
spec:
replicas: 1
replicas: {{ .Replicas }}
selector:
matchLabels:
app: http-echo
Expand Down Expand Up @@ -34,6 +34,14 @@ metadata:
{{ else if .CookiePersistenceNoDynamic }}
cookie-persistence-no-dynamic: "mycookie"
{{ end }}
{{ if .SourceIPPersistence }}
source-ip-persistence: "true"
source-ip-persistence-size: "{{ .SourceIPPersistenceSize }}"
source-ip-persistence-expire: "{{ .SourceIPPersistenceExpire }}"
{{ end }}
{{ if .LoadBalance }}
load-balance: "{{ .LoadBalance }}"
{{ end }}
spec:
ports:
- name: http
Expand Down
95 changes: 95 additions & 0 deletions deploy/tests/e2e/cookie-persistence/cookie-persistence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
package cookiepersistence

import (
"encoding/json"
"net/http"
"strings"
"testing"

parser "github.com/haproxytech/client-native/v6/config-parser"
"github.com/haproxytech/client-native/v6/config-parser/options"
"github.com/haproxytech/client-native/v6/config-parser/types"

"github.com/haproxytech/kubernetes-ingress/deploy/tests/e2e"
"github.com/stretchr/testify/suite"
Expand All @@ -48,6 +50,7 @@ func TestCookiePersistenceTestSuite(t *testing.T) {
func (suite *CookiePersistenceTestSuite) Test_CookiePersistence_Dynamic() {
//------------------------
// First step : Dynamic
suite.resetTemplateData()
suite.tmplData.CookiePersistenceDynamic = true
suite.tmplData.CookiePersistenceNoDynamic = false
suite.Require().NoError(suite.test.Apply("config/deploy.yml.tmpl", suite.test.GetNS(), suite.tmplData))
Expand Down Expand Up @@ -131,6 +134,7 @@ func (suite *CookiePersistenceTestSuite) Test_CookiePersistence_Dynamic() {
// ...

func (suite *CookiePersistenceTestSuite) Test_CookiePersistence_No_Dynamic() {
suite.resetTemplateData()
suite.tmplData.CookiePersistenceNoDynamic = true
suite.tmplData.CookiePersistenceDynamic = false
suite.Require().NoError(suite.test.Apply("config/deploy.yml.tmpl", suite.test.GetNS(), suite.tmplData))
Expand Down Expand Up @@ -209,6 +213,7 @@ func (suite *CookiePersistenceTestSuite) Test_CookiePersistence_No_Dynamic() {
func (suite *CookiePersistenceTestSuite) Test_CookiePersistence_Switch() {
//---------------------------
// Step 1 : Dynamic
suite.resetTemplateData()
suite.tmplData.CookiePersistenceDynamic = true
suite.tmplData.CookiePersistenceNoDynamic = false
suite.Require().NoError(suite.test.Apply("config/deploy.yml.tmpl", suite.test.GetNS(), suite.tmplData))
Expand Down Expand Up @@ -348,3 +353,93 @@ func (suite *CookiePersistenceTestSuite) Test_CookiePersistence_Switch() {
return true
}, e2e.WaitDuration, e2e.TickDuration)
}

// Expected backend
// backend e2e-tests-cookie-persistence_svc_http-echo_http
//
// ...
// balance source
// stick-table type ip size 1m expire 30m peers localinstance
// stick on src
// ...
func (suite *CookiePersistenceTestSuite) Test_SourceIPPersistence_WithSourceHash() {
suite.resetTemplateData()
suite.tmplData.Replicas = 3
suite.tmplData.SourceIPPersistence = true
suite.tmplData.LoadBalance = "source"
suite.Require().NoError(suite.test.Apply("config/deploy.yml.tmpl", suite.test.GetNS(), suite.tmplData))

var firstHostname string
suite.Eventually(func() bool {
hostname, ok := suite.echoHostname()
if !ok {
return false
}
firstHostname = hostname
return true
}, e2e.WaitDuration, e2e.TickDuration)

for range 10 {
hostname, ok := suite.echoHostname()
suite.Require().True(ok)
suite.Require().Equal(firstHostname, hostname)
}

suite.Eventually(func() bool {
cfg, err := suite.test.GetIngressControllerFile("/etc/haproxy/haproxy.cfg")
if err != nil {
suite.T().Logf("Could not get Haproxy config: %v", err)
return false
}
return suite.hasSourceIPPersistenceConfig(cfg)
}, e2e.WaitDuration, e2e.TickDuration)
}

func (suite *CookiePersistenceTestSuite) hasSourceIPPersistenceConfig(cfg string) bool {
if !strings.Contains(cfg, "balance source") || !strings.Contains(cfg, "stick on src") {
return false
}
reader := strings.NewReader(cfg)
p, err := parser.New(options.Reader(reader))
if err != nil {
suite.T().Logf("Could not get Haproxy config parser: %v", err)
return false
}
beName := suite.test.GetNS() + "_svc_http-echo_http"
rawTable, err := p.Get(parser.Backends, beName, "stick-table")
if err != nil {
suite.T().Logf("Could not get stick-table for backend %s: %v", beName, err)
return false
}
table, ok := rawTable.(*types.StickTable)
if !ok {
suite.T().Logf("Unexpected stick-table type %T", rawTable)
return false
}
return table.Type == "ip" &&
table.Size == "1m" &&
(table.Expire == "30m" || table.Expire == "1800000") &&
table.Peers == "localinstance"
}

func (suite *CookiePersistenceTestSuite) echoHostname() (string, bool) {
res, cls, err := suite.client.Do()
if res == nil {
suite.T().Log(err)
return "", false
}
defer cls()
if res.StatusCode != http.StatusOK {
return "", false
}
var body struct {
OS struct {
Hostname string `json:"hostname"`
} `json:"os"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
suite.T().Log(err)
return "", false
}
return body.OS.Hostname, body.OS.Hostname != ""
}
16 changes: 15 additions & 1 deletion deploy/tests/e2e/cookie-persistence/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,19 @@ type CookiePersistenceSuite struct {
type tmplData struct {
CookiePersistenceDynamic bool
CookiePersistenceNoDynamic bool
SourceIPPersistence bool
SourceIPPersistenceSize string
SourceIPPersistenceExpire string
LoadBalance string
Replicas int
Host string
}

func (suite *CookiePersistenceSuite) SetupSuite() {
var err error
suite.test, err = e2e.NewTest()
suite.Require().NoError(err)
suite.tmplData = tmplData{Host: suite.test.GetNS() + ".test"}
suite.resetTemplateData()
suite.client, err = e2e.NewHTTPClient(suite.tmplData.Host)
suite.Require().NoError(err)
}
Expand All @@ -59,6 +64,15 @@ func TestCookiePersistenceSuite(t *testing.T) {
suite.Run(t, new(CookiePersistenceSuite))
}

func (suite *CookiePersistenceSuite) resetTemplateData() {
suite.tmplData = tmplData{
Host: suite.test.GetNS() + ".test",
Replicas: 1,
SourceIPPersistenceSize: "1m",
SourceIPPersistenceExpire: "30m",
}
}

// Check that the server serverName for backend backendName
// has a "cookie" params with a value equal to the server name
func (suite *CookiePersistenceSuite) checkServerCookie(p parser.Parser, backendName, serverName string) {
Expand Down
14 changes: 12 additions & 2 deletions deploy/tests/e2e/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

package e2e

import "strings"
import (
"fmt"
"strings"
)

func (t Test) GetIngressControllerFile(path string) (string, error) {
po, err := t.getIngressControllerPod()
Expand All @@ -30,5 +33,12 @@ func (t Test) GetIngressControllerFile(path string) (string, error) {
func (t Test) getIngressControllerPod() (string, error) {
out, errExec := t.execute("", "kubectl", "get", "pods", "-n", "haproxy-controller",
"-l", "run=haproxy-ingress", "-o", "name", "--field-selector=status.phase==Running", "-l", "run=haproxy-ingress")
return strings.TrimSuffix(out, "\n"), errExec
if errExec != nil {
return "", errExec
}
pods := strings.Fields(out)
if len(pods) == 0 {
return "", fmt.Errorf("no running ingress controller pod found")
}
return pods[0], nil
}
82 changes: 82 additions & 0 deletions documentation/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ more info about custom annotations can be found in [annotations-custom.md](annot
| [backend-config-snippet](#config-snippet) | string | | |:large_blue_circle:|:large_blue_circle:|:large_blue_circle:|
| [cookie-persistence](#cookie-persistence) | string | | |:large_blue_circle:|:white_circle:|:large_blue_circle:|
| [cookie-persistence-no-dynamic](#cookie-persistence-no-dynamic) | string | | |:large_blue_circle:|:white_circle:|:large_blue_circle:|
| [source-ip-persistence](#source-ip-persistence) :construction:(dev) | [bool](#bool) | | |:large_blue_circle:|:white_circle:|:large_blue_circle:|
| [source-ip-persistence-size](#source-ip-persistence) :construction:(dev) | string | "1m" | source-ip-persistence |:large_blue_circle:|:white_circle:|:large_blue_circle:|
| [source-ip-persistence-expire](#source-ip-persistence) :construction:(dev) | string | "30m" | source-ip-persistence |:large_blue_circle:|:white_circle:|:large_blue_circle:|
| [dontlognull](#logging) | [bool](#bool) | "true" | |:large_blue_circle:|:white_circle:|:white_circle:|
| [src-ip-header](#src-ip-header) | string | "null" | |:large_blue_circle:|:large_blue_circle:|:white_circle:|
| [forwarded-for](#x-forwarded-for) | [bool](#bool) | "true" | |:large_blue_circle:|:large_blue_circle:|:large_blue_circle:|
Expand Down Expand Up @@ -1255,6 +1258,8 @@ rate-limit-status-code: "429"

:information_source: To track the http requests rate, a stick-table named "Ratelimit-<period-in-ms>" will be created. For example, if the `rate-limit-period` is set to *2s*, the name of the table will be *Ratelimit-2000*.

:information_source: Generated rate-limit stick tables use the `localinstance` peer section. When multiple controller pods expose a reachable `--localpeer-port`, the controller reconciles peer entries for pods in the same controller workload so stick-table updates can replicate between those pods.

Possible values:

- An integer representing the maximum number of requests to accept
Expand Down Expand Up @@ -1655,6 +1660,83 @@ set-host: "example.local"

***

#### Source Ip Persistence

##### `source-ip-persistence`


> :construction: this is only available from next version, currently available in dev build

Enables source IP based persistent connections (sticky sessions) between a client and a pod by creating a backend stick table and sticking on the request source address.
This persistence mode works for HTTP and TCP backends and can be used together with source-hash or MagLev-style balancing so that hash-based placement chooses the initial pod while the stick table preserves the selected pod across later requests and failover events.

Available on: `configmap` `service`

:information_source: When enabled, the controller inserts `stick-table type ip size <source-ip-persistence-size> expire <source-ip-persistence-expire> peers localinstance` and `stick on src` in the corresponding backend.

:information_source: Generated source-IP persistence stick tables use the `localinstance` peer section. When multiple controller pods expose a reachable `--localpeer-port`, the controller reconciles peer entries for pods in the same controller workload so stick-table updates can replicate between those pods.

:information_source: Backend endpoint addresses are assigned to server slots deterministically so every controller replica uses the same `SRV_<n>` identity for source-hash balancing and peer-replicated stick-table entries.

:information_source: This annotation is resolved at the service level, falling back to the configmap default. As the HAProxy backend is shared by every ingress referencing the same service, setting it on an ingress is ignored to avoid a non-deterministic backend configuration. The service value takes precedence over the configmap default.

Possible values:

- true
- false

Example:

```yaml
source-ip-persistence: "true"
```

##### `source-ip-persistence-size`


> :construction: this is only available from next version, currently available in dev build

Sets the size of the source IP persistence stick table.

Available on: `configmap` `service`

:information_source: This value is only used when `source-ip-persistence` is enabled.

Possible values:

- A valid HAProxy stick-table size, such as `100k`, `1m`, or a plain integer value

Example:

```yaml
source-ip-persistence-size: "1m"
```

##### `source-ip-persistence-expire`


> :construction: this is only available from next version, currently available in dev build

Sets how long an idle source IP persistence table entry is kept.

Available on: `configmap` `service`

:information_source: This value is only used when `source-ip-persistence` is enabled.

Possible values:

- A valid HAProxy time value, such as `30s`, `30m`, or `1h`

Example:

```yaml
source-ip-persistence-expire: "30m"
```

<p align='right'><a href='#available-annotations'>:arrow_up_small: back to top</a></p>

***

#### Src Ip Header

##### `src-ip-header`
Expand Down
19 changes: 19 additions & 0 deletions documentation/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Image can be run with arguments:
| [`--ipv6-bind-address`](#--ipv6-bind-address) | `::` |
| [`--http-bind-port`](#--http-bind-port) | `8080` |
| [`--https-bind-port`](#--https-bind-port) | `8443` |
| [`--localpeer-port`](#--localpeer-port) | `10000` |
| [`--disable-http`](#--disable-http) | `false` |
| [`--disable-https`](#--disable-https) | `false` |
| [`--sync-period`](#--sync-period) | `5s` |
Expand Down Expand Up @@ -535,6 +536,24 @@ Example:

***

### `--localpeer-port`

Sets the TCP port used in HAProxy peer entries managed by the controller's `localinstance` peer section. HAProxy uses this peer section for generated stick tables, including request rate limiting. The controller manages the running pod's peer entry and reconciles entries for pods that belong to the same controller Deployment, DaemonSet, or ReplicaSet. Expose this port between controller pods when peer replication is required. This option is not a general static peer mesh configuration; manually defined peer sections are not reconciled by this controller logic.

Possible values:

- A valid port in the range. Default: 10000

Example:

```yaml
--localpeer-port=10000
```

<p align='right'><a href='#haproxy-kubernetes-ingress-controller'>:arrow_up_small: back to top</a></p>

***

### `--disable-http`

Disabling the HTTP frontend.
Expand Down
Loading