From 8a30f4c11ffde203067fce62f9a618f6d2a2b0d5 Mon Sep 17 00:00:00 2001 From: wasker Date: Thu, 29 Jul 2021 14:23:19 -0700 Subject: [PATCH] Enable session affinity for canaries (#7371) --- build/run-in-docker.sh | 3 + docs/examples/affinity/cookie/README.md | 1 + .../nginx-configuration/annotations.md | 7 +- .../ingress/annotations/annotations_test.go | 62 +-- .../annotations/sessionaffinity/main.go | 20 +- internal/ingress/controller/controller.go | 10 +- .../ingress/controller/controller_test.go | 323 +++++++++++++- rootfs/etc/nginx/lua/balancer.lua | 21 +- rootfs/etc/nginx/lua/balancer/chashsubset.lua | 4 + rootfs/etc/nginx/lua/balancer/ewma.lua | 4 + rootfs/etc/nginx/lua/balancer/resty.lua | 4 + rootfs/etc/nginx/lua/balancer/sticky.lua | 39 +- .../nginx/lua/test/balancer/sticky_test.lua | 347 ++++++++++----- rootfs/etc/nginx/lua/test/balancer_test.lua | 399 +++++++++++------- rootfs/etc/nginx/lua/test/util/split.lua | 15 - rootfs/etc/nginx/lua/test/util/split_test.lua | 57 +++ rootfs/etc/nginx/lua/util/split.lua | 15 + 17 files changed, 1015 insertions(+), 316 deletions(-) delete mode 100644 rootfs/etc/nginx/lua/test/util/split.lua create mode 100644 rootfs/etc/nginx/lua/test/util/split_test.lua diff --git a/build/run-in-docker.sh b/build/run-in-docker.sh index d6723c7a18..e31b985247 100755 --- a/build/run-in-docker.sh +++ b/build/run-in-docker.sh @@ -25,6 +25,9 @@ set -o pipefail # temporal directory for the /etc/ingress-controller directory INGRESS_VOLUME=$(mktemp -d) +# make sure directory for SSL cert storage exists under ingress volume +mkdir "${INGRESS_VOLUME}/ssl" + if [[ "$OSTYPE" == darwin* ]]; then INGRESS_VOLUME=/private$INGRESS_VOLUME fi diff --git a/docs/examples/affinity/cookie/README.md b/docs/examples/affinity/cookie/README.md index 670d345e3f..4f93fc0f85 100644 --- a/docs/examples/affinity/cookie/README.md +++ b/docs/examples/affinity/cookie/README.md @@ -10,6 +10,7 @@ Session affinity can be configured using the following annotations: | --- | --- | --- | |nginx.ingress.kubernetes.io/affinity|Type of the affinity, set this to `cookie` to enable session affinity|string (NGINX only supports `cookie`)| |nginx.ingress.kubernetes.io/affinity-mode|The affinity mode defines how sticky a session is. Use `balanced` to redistribute some sessions when scaling pods or `persistent` for maximum stickiness.|`balanced` (default) or `persistent`| +|nginx.ingress.kubernetes.io/affinity-canary-behavior|Defines session affinity behavior of canaries. By default the behavior is `sticky`, and canaries respect session affinity configuration. Set this to `legacy` to restore original canary behavior, when session affinity parameters were not respected.|`sticky` (default) or `legacy`| |nginx.ingress.kubernetes.io/session-cookie-name|Name of the cookie that will be created|string (defaults to `INGRESSCOOKIE`)| |nginx.ingress.kubernetes.io/session-cookie-path|Path that will be set on the cookie (required if your [Ingress paths][ingress-paths] use regular expressions)|string (defaults to the currently [matched path][ingress-paths])| |nginx.ingress.kubernetes.io/session-cookie-samesite|SameSite attribute to apply to the cookie|Browser accepted values are `None`, `Lax`, and `Strict`| diff --git a/docs/user-guide/nginx-configuration/annotations.md b/docs/user-guide/nginx-configuration/annotations.md index add5c5595e..ef9a4e5ef5 100755 --- a/docs/user-guide/nginx-configuration/annotations.md +++ b/docs/user-guide/nginx-configuration/annotations.md @@ -18,6 +18,7 @@ You can add these Kubernetes annotations to specific Ingress objects to customiz |[nginx.ingress.kubernetes.io/app-root](#rewrite)|string| |[nginx.ingress.kubernetes.io/affinity](#session-affinity)|cookie| |[nginx.ingress.kubernetes.io/affinity-mode](#session-affinity)|"balanced" or "persistent"| +|[nginx.ingress.kubernetes.io/affinity-canary-behavior](#session-affinity)|"sticky" or "legacy"| |[nginx.ingress.kubernetes.io/auth-realm](#authentication)|string| |[nginx.ingress.kubernetes.io/auth-secret](#authentication)|string| |[nginx.ingress.kubernetes.io/auth-secret-type](#authentication)|string| @@ -140,7 +141,7 @@ In some cases, you may want to "canary" a new set of changes by sending a small Canary rules are evaluated in order of precedence. Precedence is as follows: `canary-by-header -> canary-by-cookie -> canary-weight` -**Note** that when you mark an ingress as canary, then all the other non-canary annotations will be ignored (inherited from the corresponding main ingress) except `nginx.ingress.kubernetes.io/load-balance` and `nginx.ingress.kubernetes.io/upstream-hash-by`. +**Note** that when you mark an ingress as canary, then all the other non-canary annotations will be ignored (inherited from the corresponding main ingress) except `nginx.ingress.kubernetes.io/load-balance`, `nginx.ingress.kubernetes.io/upstream-hash-by`, and [annotations related to session affinity](#session-affinity). If you want to restore the original behavior of canaries when session affinity was ignored, set `nginx.ingress.kubernetes.io/affinity-canary-behavior` annotation with value `legacy` on the non-canary ingress definition. **Known Limitations** @@ -163,6 +164,8 @@ The only affinity type available for NGINX is `cookie`. The annotation `nginx.ingress.kubernetes.io/affinity-mode` defines the stickiness of a session. Setting this to `balanced` (default) will redistribute some sessions if a deployment gets scaled up, therefore rebalancing the load on the servers. Setting this to `persistent` will not rebalance sessions to new servers, therefore providing maximum stickiness. +The annotation `nginx.ingress.kubernetes.io/affinity-canary-behavior` defines the behavior of canaries when session affinity is enabled. Setting this to `sticky` (default) will ensure that users that were served by canaries, will continue to be served by canaries. Setting this to `legacy` will restore original canary behavior, when session affinity was ignored. + !!! attention If more than one Ingress is defined for a host and at least one Ingress uses `nginx.ingress.kubernetes.io/affinity: cookie`, then only paths on the Ingress using `nginx.ingress.kubernetes.io/affinity` will use session cookie affinity. All paths defined on other Ingresses for the host will be load balanced through the random selection of a backend server. @@ -342,7 +345,7 @@ CORS can be controlled with the following annotations: - Example: `nginx.ingress.kubernetes.io/cors-allow-headers: "X-Forwarded-For, X-app123-XPTO"` * `nginx.ingress.kubernetes.io/cors-expose-headers` - controls which headers are exposed to response. This is a multi-valued field, separated by ',' and accepts + controls which headers are exposed to response. This is a multi-valued field, separated by ',' and accepts letters, numbers, _, - and *. - Default: *empty* - Example: `nginx.ingress.kubernetes.io/cors-expose-headers: "*, X-CustomResponseHeader"` diff --git a/internal/ingress/annotations/annotations_test.go b/internal/ingress/annotations/annotations_test.go index 1a428e729a..de02a8a16b 100644 --- a/internal/ingress/annotations/annotations_test.go +++ b/internal/ingress/annotations/annotations_test.go @@ -30,19 +30,20 @@ import ( ) var ( - annotationPassthrough = parser.GetAnnotationWithPrefix("ssl-passthrough") - annotationAffinityType = parser.GetAnnotationWithPrefix("affinity") - annotationAffinityMode = parser.GetAnnotationWithPrefix("affinity-mode") - annotationCorsEnabled = parser.GetAnnotationWithPrefix("enable-cors") - annotationCorsAllowMethods = parser.GetAnnotationWithPrefix("cors-allow-methods") - annotationCorsAllowHeaders = parser.GetAnnotationWithPrefix("cors-allow-headers") - annotationCorsExposeHeaders = parser.GetAnnotationWithPrefix("cors-expose-headers") - annotationCorsAllowCredentials = parser.GetAnnotationWithPrefix("cors-allow-credentials") - defaultCorsMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS" - defaultCorsHeaders = "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization" - annotationAffinityCookieName = parser.GetAnnotationWithPrefix("session-cookie-name") - annotationUpstreamHashBy = parser.GetAnnotationWithPrefix("upstream-hash-by") - annotationCustomHTTPErrors = parser.GetAnnotationWithPrefix("custom-http-errors") + annotationPassthrough = parser.GetAnnotationWithPrefix("ssl-passthrough") + annotationAffinityType = parser.GetAnnotationWithPrefix("affinity") + annotationAffinityMode = parser.GetAnnotationWithPrefix("affinity-mode") + annotationAffinityCanaryBehavior = parser.GetAnnotationWithPrefix("affinity-canary-behavior") + annotationCorsEnabled = parser.GetAnnotationWithPrefix("enable-cors") + annotationCorsAllowMethods = parser.GetAnnotationWithPrefix("cors-allow-methods") + annotationCorsAllowHeaders = parser.GetAnnotationWithPrefix("cors-allow-headers") + annotationCorsExposeHeaders = parser.GetAnnotationWithPrefix("cors-expose-headers") + annotationCorsAllowCredentials = parser.GetAnnotationWithPrefix("cors-allow-credentials") + defaultCorsMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS" + defaultCorsHeaders = "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization" + annotationAffinityCookieName = parser.GetAnnotationWithPrefix("session-cookie-name") + annotationUpstreamHashBy = parser.GetAnnotationWithPrefix("upstream-hash-by") + annotationCustomHTTPErrors = parser.GetAnnotationWithPrefix("custom-http-errors") ) type mockCfg struct { @@ -162,29 +163,38 @@ func TestAffinitySession(t *testing.T) { ing := buildIngress() fooAnns := []struct { - annotations map[string]string - affinitytype string - affinitymode string - name string + annotations map[string]string + affinitytype string + affinitymode string + cookiename string + canarybehavior string }{ - {map[string]string{annotationAffinityType: "cookie", annotationAffinityMode: "balanced", annotationAffinityCookieName: "route"}, "cookie", "balanced", "route"}, - {map[string]string{annotationAffinityType: "cookie", annotationAffinityMode: "persistent", annotationAffinityCookieName: "route1"}, "cookie", "persistent", "route1"}, - {map[string]string{annotationAffinityType: "cookie", annotationAffinityMode: "balanced", annotationAffinityCookieName: ""}, "cookie", "balanced", "INGRESSCOOKIE"}, - {map[string]string{}, "", "", ""}, - {nil, "", "", ""}, + {map[string]string{annotationAffinityType: "cookie", annotationAffinityMode: "balanced", annotationAffinityCookieName: "route", annotationAffinityCanaryBehavior: ""}, "cookie", "balanced", "route", ""}, + {map[string]string{annotationAffinityType: "cookie", annotationAffinityMode: "persistent", annotationAffinityCookieName: "route1", annotationAffinityCanaryBehavior: "sticky"}, "cookie", "persistent", "route1", "sticky"}, + {map[string]string{annotationAffinityType: "cookie", annotationAffinityMode: "balanced", annotationAffinityCookieName: "", annotationAffinityCanaryBehavior: "legacy"}, "cookie", "balanced", "INGRESSCOOKIE", "legacy"}, + {map[string]string{}, "", "", "", ""}, + {nil, "", "", "", ""}, } for _, foo := range fooAnns { ing.SetAnnotations(foo.annotations) r := ec.Extract(ing).SessionAffinity - t.Logf("Testing pass %v %v", foo.affinitytype, foo.name) + t.Logf("Testing pass %v %v", foo.affinitytype, foo.cookiename) + + if r.Type != foo.affinitytype { + t.Errorf("Returned %v but expected %v for Type", r.Type, foo.affinitytype) + } if r.Mode != foo.affinitymode { - t.Errorf("Returned %v but expected %v for Name", r.Mode, foo.affinitymode) + t.Errorf("Returned %v but expected %v for Mode", r.Mode, foo.affinitymode) + } + + if r.CanaryBehavior != foo.canarybehavior { + t.Errorf("Returned %v but expected %v for CanaryBehavior", r.CanaryBehavior, foo.canarybehavior) } - if r.Cookie.Name != foo.name { - t.Errorf("Returned %v but expected %v for Name", r.Cookie.Name, foo.name) + if r.Cookie.Name != foo.cookiename { + t.Errorf("Returned %v but expected %v for Cookie.Name", r.Cookie.Name, foo.cookiename) } } } diff --git a/internal/ingress/annotations/sessionaffinity/main.go b/internal/ingress/annotations/sessionaffinity/main.go index c5b340ec75..80b24f13bf 100644 --- a/internal/ingress/annotations/sessionaffinity/main.go +++ b/internal/ingress/annotations/sessionaffinity/main.go @@ -27,8 +27,10 @@ import ( ) const ( - annotationAffinityType = "affinity" - annotationAffinityMode = "affinity-mode" + annotationAffinityType = "affinity" + annotationAffinityMode = "affinity-mode" + annotationAffinityCanaryBehavior = "affinity-canary-behavior" + // If a cookie with this name exists, // its value is used as an index into the list of available backends. annotationAffinityCookieName = "session-cookie-name" @@ -66,6 +68,8 @@ type Config struct { Type string `json:"type"` // The affinity mode, i.e. how sticky a session is Mode string `json:"mode"` + // Affinity behavior for canaries (sticky or legacy) + CanaryBehavior string `json:"canaryBehavior"` Cookie } @@ -160,6 +164,11 @@ func (a affinity) Parse(ing *networking.Ingress) (interface{}, error) { am = "" } + cb, err := parser.GetStringAnnotation(annotationAffinityCanaryBehavior, ing) + if err != nil { + cb = "" + } + switch at { case "cookie": cookie = a.cookieAffinityParse(ing) @@ -169,8 +178,9 @@ func (a affinity) Parse(ing *networking.Ingress) (interface{}, error) { } return &Config{ - Type: at, - Mode: am, - Cookie: *cookie, + Type: at, + Mode: am, + CanaryBehavior: cb, + Cookie: *cookie, }, nil } diff --git a/internal/ingress/controller/controller.go b/internal/ingress/controller/controller.go index 7da39ee758..3eb572c505 100644 --- a/internal/ingress/controller/controller.go +++ b/internal/ingress/controller/controller.go @@ -1294,7 +1294,7 @@ func canMergeBackend(primary *ingress.Backend, alternative *ingress.Backend) boo } // Performs the merge action and checks to ensure that one two alternative backends do not merge into each other -func mergeAlternativeBackend(priUps *ingress.Backend, altUps *ingress.Backend) bool { +func mergeAlternativeBackend(ing *ingress.Ingress, priUps *ingress.Backend, altUps *ingress.Backend) bool { if priUps.NoServer { klog.Warningf("unable to merge alternative backend %v into primary backend %v because %v is a primary backend", altUps.Name, priUps.Name, priUps.Name) @@ -1308,6 +1308,10 @@ func mergeAlternativeBackend(priUps *ingress.Backend, altUps *ingress.Backend) b } } + if ing.ParsedAnnotations != nil && ing.ParsedAnnotations.SessionAffinity.CanaryBehavior != "legacy" { + priUps.SessionAffinity.DeepCopyInto(&altUps.SessionAffinity) + } + priUps.AlternativeBackends = append(priUps.AlternativeBackends, altUps.Name) @@ -1347,7 +1351,7 @@ func mergeAlternativeBackends(ing *ingress.Ingress, upstreams map[string]*ingres klog.V(2).Infof("matching backend %v found for alternative backend %v", priUps.Name, altUps.Name) - merged = mergeAlternativeBackend(priUps, altUps) + merged = mergeAlternativeBackend(ing, priUps, altUps) } } @@ -1400,7 +1404,7 @@ func mergeAlternativeBackends(ing *ingress.Ingress, upstreams map[string]*ingres klog.V(2).Infof("matching backend %v found for alternative backend %v", priUps.Name, altUps.Name) - merged = mergeAlternativeBackend(priUps, altUps) + merged = mergeAlternativeBackend(ing, priUps, altUps) } } diff --git a/internal/ingress/controller/controller_test.go b/internal/ingress/controller/controller_test.go index 22d108ca47..112aac0b1f 100644 --- a/internal/ingress/controller/controller_test.go +++ b/internal/ingress/controller/controller_test.go @@ -44,6 +44,7 @@ import ( "k8s.io/ingress-nginx/internal/ingress/annotations/canary" "k8s.io/ingress-nginx/internal/ingress/annotations/parser" "k8s.io/ingress-nginx/internal/ingress/annotations/proxyssl" + "k8s.io/ingress-nginx/internal/ingress/annotations/sessionaffinity" "k8s.io/ingress-nginx/internal/ingress/controller/config" ngx_config "k8s.io/ingress-nginx/internal/ingress/controller/config" "k8s.io/ingress-nginx/internal/ingress/controller/store" @@ -786,6 +787,326 @@ func TestMergeAlternativeBackends(t *testing.T) { }, }, }, + "alternative backend gets SessionAffinitySettings configured when CanaryBehavior is 'sticky'": { + &ingress.Ingress{ + Ingress: networking.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "example", + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networking.IngressRuleValue{ + HTTP: &networking.HTTPIngressRuleValue{ + Paths: []networking.HTTPIngressPath{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: networking.IngressBackend{ + ServiceName: "http-svc-canary", + ServicePort: intstr.IntOrString{ + IntVal: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ParsedAnnotations: &annotations.Ingress{ + SessionAffinity: sessionaffinity.Config{ + CanaryBehavior: "sticky", + }, + }, + }, + map[string]*ingress.Backend{ + "example-http-svc-80": { + Name: "example-http-svc-80", + NoServer: false, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + "example-http-svc-canary-80": { + Name: "example-http-svc-canary-80", + NoServer: true, + TrafficShapingPolicy: ingress.TrafficShapingPolicy{ + Weight: 20, + }, + }, + }, + map[string]*ingress.Server{ + "example.com": { + Hostname: "example.com", + Locations: []*ingress.Location{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: "example-http-svc-80", + }, + }, + }, + }, + map[string]*ingress.Backend{ + "example-http-svc-80": { + Name: "example-http-svc-80", + NoServer: false, + AlternativeBackends: []string{"example-http-svc-canary-80"}, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + "example-http-svc-canary-80": { + Name: "example-http-svc-canary-80", + NoServer: true, + TrafficShapingPolicy: ingress.TrafficShapingPolicy{ + Weight: 20, + }, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + }, + map[string]*ingress.Server{ + "example.com": { + Hostname: "example.com", + Locations: []*ingress.Location{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: "example-http-svc-80", + }, + }, + }, + }, + }, + "alternative backend gets SessionAffinitySettings configured when CanaryBehavior is not 'legacy'": { + &ingress.Ingress{ + Ingress: networking.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "example", + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networking.IngressRuleValue{ + HTTP: &networking.HTTPIngressRuleValue{ + Paths: []networking.HTTPIngressPath{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: networking.IngressBackend{ + ServiceName: "http-svc-canary", + ServicePort: intstr.IntOrString{ + IntVal: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ParsedAnnotations: &annotations.Ingress{ + SessionAffinity: sessionaffinity.Config{ + CanaryBehavior: "", // In fact any value but 'legacy' would do the trick. + }, + }, + }, + map[string]*ingress.Backend{ + "example-http-svc-80": { + Name: "example-http-svc-80", + NoServer: false, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + "example-http-svc-canary-80": { + Name: "example-http-svc-canary-80", + NoServer: true, + TrafficShapingPolicy: ingress.TrafficShapingPolicy{ + Weight: 20, + }, + }, + }, + map[string]*ingress.Server{ + "example.com": { + Hostname: "example.com", + Locations: []*ingress.Location{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: "example-http-svc-80", + }, + }, + }, + }, + map[string]*ingress.Backend{ + "example-http-svc-80": { + Name: "example-http-svc-80", + NoServer: false, + AlternativeBackends: []string{"example-http-svc-canary-80"}, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + "example-http-svc-canary-80": { + Name: "example-http-svc-canary-80", + NoServer: true, + TrafficShapingPolicy: ingress.TrafficShapingPolicy{ + Weight: 20, + }, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + }, + map[string]*ingress.Server{ + "example.com": { + Hostname: "example.com", + Locations: []*ingress.Location{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: "example-http-svc-80", + }, + }, + }, + }, + }, + "alternative backend doesn't get SessionAffinitySettings configured when CanaryBehavior is 'legacy'": { + &ingress.Ingress{ + Ingress: networking.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "example", + }, + Spec: networking.IngressSpec{ + Rules: []networking.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networking.IngressRuleValue{ + HTTP: &networking.HTTPIngressRuleValue{ + Paths: []networking.HTTPIngressPath{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: networking.IngressBackend{ + ServiceName: "http-svc-canary", + ServicePort: intstr.IntOrString{ + IntVal: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ParsedAnnotations: &annotations.Ingress{ + SessionAffinity: sessionaffinity.Config{ + CanaryBehavior: "legacy", + }, + }, + }, + map[string]*ingress.Backend{ + "example-http-svc-80": { + Name: "example-http-svc-80", + NoServer: false, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + "example-http-svc-canary-80": { + Name: "example-http-svc-canary-80", + NoServer: true, + TrafficShapingPolicy: ingress.TrafficShapingPolicy{ + Weight: 20, + }, + }, + }, + map[string]*ingress.Server{ + "example.com": { + Hostname: "example.com", + Locations: []*ingress.Location{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: "example-http-svc-80", + }, + }, + }, + }, + map[string]*ingress.Backend{ + "example-http-svc-80": { + Name: "example-http-svc-80", + NoServer: false, + AlternativeBackends: []string{"example-http-svc-canary-80"}, + SessionAffinity: ingress.SessionAffinityConfig{ + AffinityType: "cookie", + AffinityMode: "balanced", + CookieSessionAffinity: ingress.CookieSessionAffinity{ + Name: "test", + }, + }, + }, + "example-http-svc-canary-80": { + Name: "example-http-svc-canary-80", + NoServer: true, + TrafficShapingPolicy: ingress.TrafficShapingPolicy{ + Weight: 20, + }, + }, + }, + map[string]*ingress.Server{ + "example.com": { + Hostname: "example.com", + Locations: []*ingress.Location{ + { + Path: "/", + PathType: &pathTypePrefix, + Backend: "example-http-svc-80", + }, + }, + }, + }, + }, } for title, tc := range testCases { @@ -801,7 +1122,7 @@ func TestMergeAlternativeBackends(t *testing.T) { if !actualUpstream.Equal(expUpstream) { t.Logf("actual upstream %s alternative backends: %s", actualUpstream.Name, actualUpstream.AlternativeBackends) t.Logf("expected upstream %s alternative backends: %s", expUpstream.Name, expUpstream.AlternativeBackends) - t.Errorf("upstream %s was not equal to what was expected: ", upsName) + t.Errorf("upstream %s was not equal to what was expected", actualUpstream.Name) } } diff --git a/rootfs/etc/nginx/lua/balancer.lua b/rootfs/etc/nginx/lua/balancer.lua index afcfebb67a..e83257a6f9 100644 --- a/rootfs/etc/nginx/lua/balancer.lua +++ b/rootfs/etc/nginx/lua/balancer.lua @@ -48,7 +48,7 @@ local function get_implementation(backend) if backend["sessionAffinityConfig"] and backend["sessionAffinityConfig"]["name"] == "cookie" then - if backend["sessionAffinityConfig"]["mode"] == 'persistent' then + if backend["sessionAffinityConfig"]["mode"] == "persistent" then name = "sticky_persistent" else name = "sticky_balanced" @@ -186,6 +186,11 @@ local function sync_backends() end local function route_to_alternative_balancer(balancer) + if balancer.is_affinitized(balancer) then + -- If request is already affinitized to a primary balancer, keep the primary balancer. + return false + end + if not balancer.alternative_backends then return false end @@ -204,6 +209,13 @@ local function route_to_alternative_balancer(balancer) return false end + if alternative_balancer.is_affinitized(alternative_balancer) then + -- If request is affinitized to an alternative balancer, instruct caller to + -- switch to alternative. + return true + end + + -- Use traffic shaping policy, if request didn't have affinity set. local traffic_shaping_policy = alternative_balancer.traffic_shaping_policy if not traffic_shaping_policy then ngx.log(ngx.ERR, "traffic shaping policy is not set for balancer ", @@ -254,6 +266,10 @@ local function route_to_alternative_balancer(balancer) return false end +local function get_balancer_by_upstream_name(upstream_name) + return balancers[upstream_name] +end + local function get_balancer() if ngx.ctx.balancer then return ngx.ctx.balancer @@ -263,7 +279,7 @@ local function get_balancer() local balancer = balancers[backend_name] if not balancer then - return + return nil end if route_to_alternative_balancer(balancer) then @@ -352,6 +368,7 @@ setmetatable(_M, {__index = { sync_backend = sync_backend, route_to_alternative_balancer = route_to_alternative_balancer, get_balancer = get_balancer, + get_balancer_by_upstream_name = get_balancer_by_upstream_name, }}) return _M diff --git a/rootfs/etc/nginx/lua/balancer/chashsubset.lua b/rootfs/etc/nginx/lua/balancer/chashsubset.lua index 28c2354a1b..d9ceb471e5 100644 --- a/rootfs/etc/nginx/lua/balancer/chashsubset.lua +++ b/rootfs/etc/nginx/lua/balancer/chashsubset.lua @@ -68,6 +68,10 @@ function _M.new(self, backend) return o end +function _M.is_affinitized() + return false +end + function _M.balance(self) local key = util.generate_var_value(self.hash_by) local subset_id = self.instance:find(key) diff --git a/rootfs/etc/nginx/lua/balancer/ewma.lua b/rootfs/etc/nginx/lua/balancer/ewma.lua index ae65ccc73f..681866dc16 100644 --- a/rootfs/etc/nginx/lua/balancer/ewma.lua +++ b/rootfs/etc/nginx/lua/balancer/ewma.lua @@ -170,6 +170,10 @@ local function calculate_slow_start_ewma(self) return total_ewma / endpoints_count end +function _M.is_affinitized() + return false +end + function _M.balance(self) local peers = self.peers local endpoint, ewma_score = peers[1], -1 diff --git a/rootfs/etc/nginx/lua/balancer/resty.lua b/rootfs/etc/nginx/lua/balancer/resty.lua index c1065ff19d..12b24be14e 100644 --- a/rootfs/etc/nginx/lua/balancer/resty.lua +++ b/rootfs/etc/nginx/lua/balancer/resty.lua @@ -14,6 +14,10 @@ function _M.new(self, o) return o end +function _M.is_affinitized() + return false +end + function _M.sync(self, backend) self.traffic_shaping_policy = backend.trafficShapingPolicy self.alternative_backends = backend.alternativeBackends diff --git a/rootfs/etc/nginx/lua/balancer/sticky.lua b/rootfs/etc/nginx/lua/balancer/sticky.lua index 45ea9beaf7..63f3c67850 100644 --- a/rootfs/etc/nginx/lua/balancer/sticky.lua +++ b/rootfs/etc/nginx/lua/balancer/sticky.lua @@ -13,6 +13,7 @@ local setmetatable = setmetatable local _M = balancer_resty:new() local DEFAULT_COOKIE_NAME = "route" +local COOKIE_VALUE_DELIMITER = "|" function _M.cookie_name(self) return self.cookie_session_affinity.name or DEFAULT_COOKIE_NAME @@ -22,7 +23,8 @@ function _M.new(self) local o = { alternative_backends = nil, cookie_session_affinity = nil, - traffic_shaping_policy = nil + traffic_shaping_policy = nil, + backend_key = nil } setmetatable(o, self) @@ -31,13 +33,37 @@ function _M.new(self) return o end -function _M.get_cookie(self) +function _M.get_cookie_parsed(self) local cookie, err = ck:new() if not cookie then ngx.log(ngx.ERR, err) end - return cookie:get(self:cookie_name()) + local result = { + upstream_key = nil, + backend_key = nil + } + + local raw_value = cookie:get(self:cookie_name()) + if not raw_value then + return result + end + + local parsed_value, len = split.split_string(raw_value, COOKIE_VALUE_DELIMITER) + if len == 0 then + return result + end + + result.upstream_key = parsed_value[1] + if len > 1 then + result.backend_key = parsed_value[2] + end + + return result +end + +function _M.get_cookie(self) + return self:get_cookie_parsed().upstream_key end function _M.set_cookie(self, value) @@ -63,7 +89,7 @@ function _M.set_cookie(self, value) local cookie_data = { key = self:cookie_name(), - value = value, + value = value .. COOKIE_VALUE_DELIMITER .. self.backend_key, path = cookie_path, httponly = true, samesite = cookie_samesite, @@ -86,6 +112,10 @@ function _M.set_cookie(self, value) end end +function _M.is_affinitized(self) + return self:get_cookie_parsed().backend_key == self.backend_key +end + function _M.get_last_failure() return ngx_balancer.get_last_failure() end @@ -166,6 +196,7 @@ function _M.sync(self, backend) self.traffic_shaping_policy = backend.trafficShapingPolicy self.alternative_backends = backend.alternativeBackends self.cookie_session_affinity = backend.sessionAffinityConfig.cookieSessionAffinity + self.backend_key = ngx.md5(ngx.md5(backend.name) .. backend.name) end return _M diff --git a/rootfs/etc/nginx/lua/test/balancer/sticky_test.lua b/rootfs/etc/nginx/lua/test/balancer/sticky_test.lua index 44e103c1c6..a0c0ae54f0 100644 --- a/rootfs/etc/nginx/lua/test/balancer/sticky_test.lua +++ b/rootfs/etc/nginx/lua/test/balancer/sticky_test.lua @@ -5,22 +5,33 @@ local util = require("util") local original_ngx = ngx -function mock_ngx(mock) +local function reset_sticky_balancer() + package.loaded["balancer.sticky"] = nil + package.loaded["balancer.sticky_balanced"] = nil + package.loaded["balancer.sticky_persistent"] = nil + + sticky_balanced = require("balancer.sticky_balanced") + sticky_persistent = require("balancer.sticky_persistent") +end + +local function mock_ngx(mock, after_mock_set) local _ngx = mock - setmetatable(_ngx, {__index = _G.ngx}) + setmetatable(_ngx, { __index = ngx }) _G.ngx = _ngx + + if after_mock_set then + after_mock_set() + end + + -- Balancer module caches ngx module, must be reset after mocks were configured. + reset_sticky_balancer() end local function reset_ngx() _G.ngx = original_ngx -end -local function reset_sticky_balancer() - package.loaded["balancer.sticky"] = nil - package.loaded["balancer.sticky_balanced"] = nil - package.loaded["balancer.sticky_persistent"] = nil - sticky_balanced = require("balancer.sticky_balanced") - sticky_persistent = require("balancer.sticky_persistent") + -- Ensure balancer cache is reset. + _G.ngx.ctx.balancer = nil end function get_mocked_cookie_new() @@ -55,7 +66,6 @@ end describe("Sticky", function() before_each(function() mock_ngx({ var = { location_path = "/", host = "test.com" } }) - reset_sticky_balancer() end) after_each(function() @@ -65,29 +75,44 @@ describe("Sticky", function() local test_backend = get_test_backend() local test_backend_endpoint= test_backend.endpoints[1].address .. ":" .. test_backend.endpoints[1].port + local legacy_cookie_value = test_backend_endpoint + local function create_current_cookie_value(backend_key) + return test_backend_endpoint .. "|" .. backend_key + end + describe("new(backend)", function() - context("when backend specifies cookie name", function() - local function test(sticky) - local sticky_balancer_instance = sticky:new(test_backend) + describe("when backend specifies cookie name", function() + local function test_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) local test_backend_cookie_name = test_backend.sessionAffinityConfig.cookieSessionAffinity.name assert.equal(sticky_balancer_instance:cookie_name(), test_backend_cookie_name) end - it("returns an instance containing the corresponding cookie name", function() test(sticky_balanced) end) - it("returns an instance containing the corresponding cookie name", function() test(sticky_persistent) end) + it("returns an instance containing the corresponding cookie name", function() test_with(sticky_balanced) end) + it("returns an instance containing the corresponding cookie name", function() test_with(sticky_persistent) end) end) - context("when backend does not specify cookie name", function() - local function test(sticky) + describe("when backend does not specify cookie name", function() + local function test_with(sticky_balancer_type) local temp_backend = util.deepcopy(test_backend) temp_backend.sessionAffinityConfig.cookieSessionAffinity.name = nil - local sticky_balancer_instance = sticky:new(temp_backend) + local sticky_balancer_instance = sticky_balancer_type:new(temp_backend) local default_cookie_name = "route" assert.equal(sticky_balancer_instance:cookie_name(), default_cookie_name) end - it("returns an instance with 'route' as cookie name", function() test(sticky_balanced) end) - it("returns an instance with 'route' as cookie name", function() test(sticky_persistent) end) + it("returns an instance with 'route' as cookie name", function() test_with(sticky_balanced) end) + it("returns an instance with 'route' as cookie name", function() test_with(sticky_persistent) end) + end) + + describe("backend_key", function() + local function test_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) + assert.is_truthy(sticky_balancer_instance.backend_key) + end + + it("calculates at construction time", function() test_with(sticky_balanced) end) + it("calculates at construction time", function() test_with(sticky_persistent) end) end) end) @@ -95,28 +120,25 @@ describe("Sticky", function() local mocked_cookie_new = cookie.new before_each(function() - package.loaded["balancer.sticky_balanced"] = nil - package.loaded["balancer.sticky_persistent"] = nil - sticky_balanced = require("balancer.sticky_balanced") - sticky_persistent = require("balancer.sticky_persistent") + reset_sticky_balancer() end) after_each(function() cookie.new = mocked_cookie_new end) - context("when client doesn't have a cookie set and location is in cookie_locations", function() + describe("when client doesn't have a cookie set and location is in cookie_locations", function() - local function test_pick_endpoint(sticky) - local sticky_balancer_instance = sticky:new(test_backend) + local function test_pick_endpoint_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) local peer = sticky_balancer_instance:balance() assert.equal(test_backend_endpoint, peer) end - it("picks an endpoint for the client", function() test_pick_endpoint(sticky_balanced) end) - it("picks an endpoint for the client", function() test_pick_endpoint(sticky_persistent) end) + it("picks an endpoint for the client", function() test_pick_endpoint_with(sticky_balanced) end) + it("picks an endpoint for the client", function() test_pick_endpoint_with(sticky_persistent) end) - local function test_set_cookie(sticky) + local function test_set_cookie_with(sticky_balancer_type) local s = {} cookie.new = function(self) local cookie_instance = { @@ -137,15 +159,15 @@ describe("Sticky", function() local b = get_test_backend() b.sessionAffinityConfig.cookieSessionAffinity.locations = {} b.sessionAffinityConfig.cookieSessionAffinity.locations["test.com"] = {"/"} - local sticky_balancer_instance = sticky:new(b) + local sticky_balancer_instance = sticky_balancer_type:new(b) assert.has_no.errors(function() sticky_balancer_instance:balance() end) assert.spy(s).was_called() end - it("sets a cookie on the client", function() test_set_cookie(sticky_balanced) end) - it("sets a cookie on the client", function() test_set_cookie(sticky_persistent) end) + it("sets a cookie on the client", function() test_set_cookie_with(sticky_balanced) end) + it("sets a cookie on the client", function() test_set_cookie_with(sticky_persistent) end) - local function test_set_ssl_cookie(sticky) + local function test_set_ssl_cookie_with(sticky_balancer_type) ngx.var.https = "on" local s = {} cookie.new = function(self) @@ -167,21 +189,17 @@ describe("Sticky", function() local b = get_test_backend() b.sessionAffinityConfig.cookieSessionAffinity.locations = {} b.sessionAffinityConfig.cookieSessionAffinity.locations["test.com"] = {"/"} - local sticky_balancer_instance = sticky:new(b) + local sticky_balancer_instance = sticky_balancer_type:new(b) assert.has_no.errors(function() sticky_balancer_instance:balance() end) assert.spy(s).was_called() end - it("sets a secure cookie on the client when being in ssl mode", function() - test_set_ssl_cookie(sticky_balanced) - end) - it("sets a secure cookie on the client when being in ssl mode", function() - test_set_ssl_cookie(sticky_persistent) - end) + it("sets a secure cookie on the client when being in ssl mode", function() test_set_ssl_cookie_with(sticky_balanced) end) + it("sets a secure cookie on the client when being in ssl mode", function() test_set_ssl_cookie_with(sticky_persistent) end) end) - context("when client doesn't have a cookie set and cookie_locations contains a matching wildcard location", - function() + describe("when client doesn't have a cookie set and cookie_locations contains a matching wildcard location", function() + before_each(function () ngx.var.host = "dev.test.com" end) @@ -189,7 +207,7 @@ describe("Sticky", function() ngx.var.host = "test.com" end) - local function test(sticky) + local function test_with(sticky_balancer_type) local s = {} cookie.new = function(self) local cookie_instance = { @@ -211,27 +229,27 @@ describe("Sticky", function() local b = get_test_backend() b.sessionAffinityConfig.cookieSessionAffinity.locations = {} b.sessionAffinityConfig.cookieSessionAffinity.locations["*.test.com"] = {"/"} - local sticky_balancer_instance = sticky:new(b) + local sticky_balancer_instance = sticky_balancer_type:new(b) assert.has_no.errors(function() sticky_balancer_instance:balance() end) assert.spy(s).was_called() end - it("sets a cookie on the client", function() test(sticky_balanced) end) - it("sets a cookie on the client", function() test(sticky_persistent) end) + it("sets a cookie on the client", function() test_with(sticky_balanced) end) + it("sets a cookie on the client", function() test_with(sticky_persistent) end) end) - context("when client doesn't have a cookie set and location not in cookie_locations", function() + describe("when client doesn't have a cookie set and location not in cookie_locations", function() - local function test_pick_endpoint(sticky) - local sticky_balancer_instance = sticky:new(test_backend) + local function test_pick_endpoint_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) local peer = sticky_balancer_instance:balance() assert.equal(peer, test_backend_endpoint) end - it("picks an endpoint for the client", function() test_pick_endpoint(sticky_balanced) end) - it("picks an endpoint for the client", function() test_pick_endpoint(sticky_persistent) end) + it("picks an endpoint for the client", function() test_pick_endpoint_with(sticky_balanced) end) + it("picks an endpoint for the client", function() test_pick_endpoint_with(sticky_persistent) end) - local function test_no_cookie(sticky) + local function test_no_cookie_with(sticky_balancer_type) local s = {} cookie.new = function(self) local cookie_instance = { @@ -248,34 +266,34 @@ describe("Sticky", function() s = spy.on(cookie_instance, "set") return cookie_instance, false end - local sticky_balancer_instance = sticky:new(get_test_backend()) + local sticky_balancer_instance = sticky_balancer_type:new(get_test_backend()) assert.has_no.errors(function() sticky_balancer_instance:balance() end) assert.spy(s).was_not_called() end - it("does not set a cookie on the client", function() test_no_cookie(sticky_balanced) end) - it("does not set a cookie on the client", function() test_no_cookie(sticky_persistent) end) + it("does not set a cookie on the client", function() test_no_cookie_with(sticky_balanced) end) + it("does not set a cookie on the client", function() test_no_cookie_with(sticky_persistent) end) end) - context("when client has a cookie set", function() + describe("when client has a cookie set", function() - local function test_no_cookie(sticky) + local function test_no_cookie_with(sticky_balancer_type) local s = {} cookie.new = function(self) local return_obj = { set = function(v) return false, nil end, - get = function(k) return test_backend_endpoint end, + get = function(k) return legacy_cookie_value end, } s = spy.on(return_obj, "set") return return_obj, false end - local sticky_balancer_instance = sticky:new(test_backend) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) assert.has_no.errors(function() sticky_balancer_instance:balance() end) assert.spy(s).was_not_called() end - it("does not set a cookie", function() test_no_cookie(sticky_balanced) end) - it("does not set a cookie", function() test_no_cookie(sticky_persistent) end) + it("does not set a cookie", function() test_no_cookie_with(sticky_balanced) end) + it("does not set a cookie", function() test_no_cookie_with(sticky_persistent) end) local function test_correct_endpoint(sticky) local sticky_balancer_instance = sticky:new(test_backend) @@ -312,17 +330,16 @@ describe("Sticky", function() before_each(function() mock_ngx({ var = { location_path = "/", host = "test.com" } }) - reset_sticky_balancer() end) after_each(function() reset_ngx() end) - context("when request to upstream fails", function() + describe("when request to upstream fails", function() - local function test(sticky, change_on_failure) - local sticky_balancer_instance = sticky:new(get_several_test_backends(change_on_failure)) + local function test_with(sticky_balancer_type, change_on_failure) + local sticky_balancer_instance = sticky_balancer_type:new(get_several_test_backends(change_on_failure)) local old_upstream = sticky_balancer_instance:balance() assert.is.Not.Nil(old_upstream) @@ -349,29 +366,21 @@ describe("Sticky", function() end end - it("changes upstream when change_on_failure option is true", function() - test(sticky_balanced, true) - end) - it("changes upstream when change_on_failure option is true", function() - test(sticky_balanced, false) - end) - it("changes upstream when change_on_failure option is true", function() - test(sticky_persistent, true) - end) - it("changes upstream when change_on_failure option is true", function() - test(sticky_persistent, false) - end) + it("changes upstream when change_on_failure option is true", function() test_with(sticky_balanced, true) end) + it("changes upstream when change_on_failure option is true", function() test_with(sticky_persistent, true) end) + + it("changes upstream when change_on_failure option is false", function() test_with(sticky_balanced, false) end) + it("changes upstream when change_on_failure option is false", function() test_with(sticky_persistent, false) end) end) end) - context("when client doesn't have a cookie set and no host header, matching default server '_'", - function() + describe("when client doesn't have a cookie set and no host header, matching default server '_'", function() before_each(function () ngx.var.host = "not-default-server" ngx.var.server_name = "_" end) - local function test(sticky) + local function test_with(sticky_balancer_type) local s = {} cookie.new = function(self) local cookie_instance = { @@ -393,30 +402,27 @@ describe("Sticky", function() local b = get_test_backend() b.sessionAffinityConfig.cookieSessionAffinity.locations = {} b.sessionAffinityConfig.cookieSessionAffinity.locations["_"] = {"/"} - local sticky_balancer_instance = sticky:new(b) + local sticky_balancer_instance = sticky_balancer_type:new(b) assert.has_no.errors(function() sticky_balancer_instance:balance() end) assert.spy(s).was_called() end - it("sets a cookie on the client", function() test(sticky_balanced) end) - it("sets a cookie on the client", function() test(sticky_persistent) end) + it("sets a cookie on the client", function() test_with(sticky_balanced) end) + it("sets a cookie on the client", function() test_with(sticky_persistent) end) end) describe("SameSite settings", function() local mocked_cookie_new = cookie.new before_each(function() - package.loaded["balancer.sticky_balanced"] = nil - package.loaded["balancer.sticky_persistent"] = nil - sticky_balanced = require("balancer.sticky_balanced") - sticky_persistent = require("balancer.sticky_persistent") + reset_sticky_balancer() end) after_each(function() cookie.new = mocked_cookie_new end) - local function test_set_cookie(sticky, samesite, conditional_samesite_none, expected_path, expected_samesite) + local function test_set_cookie_with(sticky_balancer_type, samesite, conditional_samesite_none, expected_path, expected_samesite) local s = {} cookie.new = function(self) local cookie_instance = { @@ -439,34 +445,179 @@ describe("Sticky", function() b.sessionAffinityConfig.cookieSessionAffinity.locations["test.com"] = {"/"} b.sessionAffinityConfig.cookieSessionAffinity.samesite = samesite b.sessionAffinityConfig.cookieSessionAffinity.conditional_samesite_none = conditional_samesite_none - local sticky_balancer_instance = sticky:new(b) + local sticky_balancer_instance = sticky_balancer_type:new(b) assert.has_no.errors(function() sticky_balancer_instance:balance() end) assert.spy(s).was_called() end it("returns a cookie with SameSite=Strict when user specifies samesite strict", function() - test_set_cookie(sticky_balanced, "Strict", false, "/", "Strict") + test_set_cookie_with(sticky_balanced, "Strict", false, "/", "Strict") end) it("returns a cookie with SameSite=Strict when user specifies samesite strict and conditional samesite none", function() - test_set_cookie(sticky_balanced, "Strict", true, "/", "Strict") + test_set_cookie_with(sticky_balanced, "Strict", true, "/", "Strict") end) it("returns a cookie with SameSite=Lax when user specifies samesite lax", function() - test_set_cookie(sticky_balanced, "Lax", false, "/", "Lax") + test_set_cookie_with(sticky_balanced, "Lax", false, "/", "Lax") end) it("returns a cookie with SameSite=Lax when user specifies samesite lax and conditional samesite none", function() - test_set_cookie(sticky_balanced, "Lax", true, "/", "Lax") + test_set_cookie_with(sticky_balanced, "Lax", true, "/", "Lax") end) it("returns a cookie with SameSite=None when user specifies samesite None", function() - test_set_cookie(sticky_balanced, "None", false, "/", "None") + test_set_cookie_with(sticky_balanced, "None", false, "/", "None") end) it("returns a cookie with SameSite=None when user specifies samesite None and conditional samesite none with supported user agent", function() mock_ngx({ var = { location_path = "/", host = "test.com" , http_user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.2704.103 Safari/537.36"} }) - test_set_cookie(sticky_balanced, "None", true, "/", "None") + test_set_cookie_with(sticky_balanced, "None", true, "/", "None") end) it("returns a cookie without SameSite=None when user specifies samesite None and conditional samesite none with unsupported user agent", function() mock_ngx({ var = { location_path = "/", host = "test.com" , http_user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"} }) - reset_sticky_balancer() - test_set_cookie(sticky_balanced, "None", true, "/", nil) + test_set_cookie_with(sticky_balanced, "None", true, "/", nil) + end) + + it("returns a cookie with SameSite=Strict when user specifies samesite strict", function() + test_set_cookie_with(sticky_persistent, "Strict", false, "/", "Strict") + end) + it("returns a cookie with SameSite=Strict when user specifies samesite strict and conditional samesite none", function() + test_set_cookie_with(sticky_persistent, "Strict", true, "/", "Strict") + end) + it("returns a cookie with SameSite=Lax when user specifies samesite lax", function() + test_set_cookie_with(sticky_persistent, "Lax", false, "/", "Lax") + end) + it("returns a cookie with SameSite=Lax when user specifies samesite lax and conditional samesite none", function() + test_set_cookie_with(sticky_persistent, "Lax", true, "/", "Lax") end) + it("returns a cookie with SameSite=None when user specifies samesite None", function() + test_set_cookie_with(sticky_persistent, "None", false, "/", "None") + end) + it("returns a cookie with SameSite=None when user specifies samesite None and conditional samesite none with supported user agent", function() + mock_ngx({ var = { location_path = "/", host = "test.com" , http_user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.2704.103 Safari/537.36"} }) + test_set_cookie_with(sticky_persistent, "None", true, "/", "None") + end) + it("returns a cookie without SameSite=None when user specifies samesite None and conditional samesite none with unsupported user agent", function() + mock_ngx({ var = { location_path = "/", host = "test.com" , http_user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"} }) + test_set_cookie_with(sticky_persistent, "None", true, "/", nil) + end) + end) + + describe("get_cookie()", function() + + describe("legacy cookie value", function() + local function test_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) + + cookie.new = function(self) + local return_obj = { + set = function(v) return false, nil end, + get = function(k) return legacy_cookie_value end, + } + return return_obj, false + end + + assert.equal(test_backend_endpoint, sticky_balancer_instance.get_cookie(sticky_balancer_instance)) + end + + it("retrieves upstream key value", function() test_with(sticky_balanced) end) + it("retrieves upstream key value", function() test_with(sticky_persistent) end) + end) + + describe("current cookie value", function() + local function test_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) + + cookie.new = function(self) + local return_obj = { + set = function(v) return false, nil end, + get = function(k) return create_current_cookie_value(sticky_balancer_instance.backend_key) end, + } + return return_obj, false + end + + assert.equal(test_backend_endpoint, sticky_balancer_instance.get_cookie(sticky_balancer_instance)) + end + + it("retrieves upstream key value", function() test_with(sticky_balanced) end) + it("retrieves upstream key value", function() test_with(sticky_persistent) end) + end) + + end) + + describe("get_cookie_parsed()", function() + + describe("legacy cookie value", function() + local function test_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) + + cookie.new = function(self) + local return_obj = { + set = function(v) return false, nil end, + get = function(k) return legacy_cookie_value end, + } + return return_obj, false + end + + local parsed_cookie = sticky_balancer_instance.get_cookie_parsed(sticky_balancer_instance) + + assert.is_truthy(parsed_cookie) + assert.equal(test_backend_endpoint, parsed_cookie.upstream_key) + assert.is_falsy(parsed_cookie.backend_key) + end + + it("retrieves upstream key value", function() test_with(sticky_balanced) end) + it("retrieves upstream key value", function() test_with(sticky_persistent) end) + end) + + describe("current cookie value", function() + local function test_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) + + cookie.new = function(self) + local return_obj = { + set = function(v) return false, nil end, + get = function(k) return create_current_cookie_value(sticky_balancer_instance.backend_key) end, + } + return return_obj, false + end + + local parsed_cookie = sticky_balancer_instance.get_cookie_parsed(sticky_balancer_instance) + + assert.is_truthy(parsed_cookie) + assert.equal(test_backend_endpoint, parsed_cookie.upstream_key) + assert.equal(sticky_balancer_instance.backend_key, parsed_cookie.backend_key) + end + + it("retrieves all supported values", function() test_with(sticky_balanced) end) + it("retrieves all supported values", function() test_with(sticky_persistent) end) + end) + + end) + + describe("set_cookie()", function() + + local function test_with(sticky_balancer_type) + local sticky_balancer_instance = sticky_balancer_type:new(test_backend) + + local cookieSetSpy = {} + cookie.new = function(self) + local return_obj = { + set = function(self, payload) + assert.equal(create_current_cookie_value(sticky_balancer_instance.backend_key), payload.value) + + return true, nil + end, + get = function(k) return nil end, + } + cookieSetSpy = spy.on(return_obj, "set") + + return return_obj, false + end + + sticky_balancer_instance.set_cookie(sticky_balancer_instance, test_backend_endpoint) + + assert.spy(cookieSetSpy).was_called() + end + + it("constructs correct cookie value", function() test_with(sticky_balanced) end) + it("constructs correct cookie value", function() test_with(sticky_persistent) end) + end) end) diff --git a/rootfs/etc/nginx/lua/test/balancer_test.lua b/rootfs/etc/nginx/lua/test/balancer_test.lua index 1834b6f60d..4f40bc6ae5 100644 --- a/rootfs/etc/nginx/lua/test/balancer_test.lua +++ b/rootfs/etc/nginx/lua/test/balancer_test.lua @@ -6,12 +6,9 @@ local original_ngx = ngx local function reset_ngx() _G.ngx = original_ngx -end -local function mock_ngx(mock) - local _ngx = mock - setmetatable(_ngx, { __index = ngx }) - _G.ngx = _ngx + -- Ensure balancer cache is reset. + _G.ngx.ctx.balancer = nil end local function reset_balancer() @@ -19,6 +16,19 @@ local function reset_balancer() balancer = require("balancer") end +local function mock_ngx(mock, after_mock_set) + local _ngx = mock + setmetatable(_ngx, { __index = ngx }) + _G.ngx = _ngx + + if after_mock_set then + after_mock_set() + end + + -- Balancer module caches ngx module, must be reset after mocks were configured. + reset_balancer() +end + local function reset_expected_implementations() expected_implementations = { ["access-router-production-web-80"] = package.loaded["balancer.round_robin"], @@ -26,7 +36,8 @@ local function reset_expected_implementations() ["my-dummy-app-2"] = package.loaded["balancer.chash"], ["my-dummy-app-3"] = package.loaded["balancer.sticky_persistent"], ["my-dummy-app-4"] = package.loaded["balancer.ewma"], - ["my-dummy-app-5"] = package.loaded["balancer.sticky_balanced"] + ["my-dummy-app-5"] = package.loaded["balancer.sticky_balanced"], + ["my-dummy-app-6"] = package.loaded["balancer.chashsubset"] } end @@ -48,20 +59,35 @@ local function reset_backends() cookie = "" }, }, - { name = "my-dummy-app-1", ["load-balance"] = "round_robin", }, { - name = "my-dummy-app-2", ["load-balance"] = "chash", + name = "my-dummy-app-1", + ["load-balance"] = "round_robin", + }, + { + name = "my-dummy-app-2", + ["load-balance"] = "round_robin", -- upstreamHashByConfig will take priority. upstreamHashByConfig = { ["upstream-hash-by"] = "$request_uri", }, }, { - name = "my-dummy-app-3", ["load-balance"] = "ewma", - sessionAffinityConfig = { name = "cookie", mode = 'persistent', cookieSessionAffinity = { name = "route" } } + name = "my-dummy-app-3", + ["load-balance"] = "ewma", -- sessionAffinityConfig will take priority. + sessionAffinityConfig = { name = "cookie", mode = "persistent", cookieSessionAffinity = { name = "route" } } }, - { name = "my-dummy-app-4", ["load-balance"] = "ewma", }, { - name = "my-dummy-app-5", ["load-balance"] = "ewma", ["upstream-hash-by"] = "$request_uri", + name = "my-dummy-app-4", + ["load-balance"] = "ewma", + }, + { + name = "my-dummy-app-5", + ["load-balance"] = "ewma", -- sessionAffinityConfig will take priority. + upstreamHashByConfig = { ["upstream-hash-by"] = "$request_uri", }, sessionAffinityConfig = { name = "cookie", cookieSessionAffinity = { name = "route" } } }, + { + name = "my-dummy-app-6", + ["load-balance"] = "ewma", -- upstreamHashByConfig will take priority. + upstreamHashByConfig = { ["upstream-hash-by"] = "$request_uri", ["upstream-hash-by-subset"] = "true", } + }, } end @@ -77,7 +103,7 @@ describe("Balancer", function() end) describe("get_implementation()", function() - it("returns correct implementation for given backend", function() + it("uses heuristics to select correct load balancer implementation for a given backend", function() for _, backend in pairs(backends) do local expected_implementation = expected_implementations[backend.name] local implementation = balancer.get_implementation(backend) @@ -89,8 +115,8 @@ describe("Balancer", function() describe("get_balancer()", function() it("always returns the same balancer for given request context", function() local backend = { - name = "my-dummy-app-6", ["load-balance"] = "ewma", - alternativeBackends = { "my-dummy-canary-app-6" }, + name = "my-dummy-app-100", ["load-balance"] = "ewma", + alternativeBackends = { "my-dummy-canary-app-100" }, endpoints = { { address = "10.184.7.40", port = "8080", maxFails = 0, failTimeout = 0 } }, trafficShapingPolicy = { weight = 0, @@ -100,8 +126,8 @@ describe("Balancer", function() }, } local canary_backend = { - name = "my-dummy-canary-app-6", ["load-balance"] = "ewma", - alternativeBackends = { "my-dummy-canary-app-6" }, + name = "my-dummy-canary-app-100", ["load-balance"] = "ewma", + alternativeBackends = { "my-dummy-canary-app-100" }, endpoints = { { address = "11.184.7.40", port = "8080", maxFails = 0, failTimeout = 0 } }, trafficShapingPolicy = { weight = 5, @@ -112,7 +138,6 @@ describe("Balancer", function() } mock_ngx({ var = { proxy_upstream_name = backend.name } }) - reset_balancer() balancer.sync_backend(backend) balancer.sync_backend(canary_backend) @@ -126,172 +151,223 @@ describe("Balancer", function() end) describe("route_to_alternative_balancer()", function() - local backend, _balancer + local backend, _primaryBalancer before_each(function() backend = backends[1] - _balancer = { + _primaryBalancer = { alternative_backends = { backend.name, } } mock_ngx({ var = { request_uri = "/" } }) - reset_balancer() end) - it("returns false when no trafficShapingPolicy is set", function() - balancer.sync_backend(backend) - assert.equal(false, balancer.route_to_alternative_balancer(_balancer)) - end) + -- Not affinitized request must follow traffic shaping policies. + describe("not affinitized", function() - it("returns false when no alternative backends is set", function() - backend.trafficShapingPolicy.weight = 100 - balancer.sync_backend(backend) - _balancer.alternative_backends = nil - assert.equal(false, balancer.route_to_alternative_balancer(_balancer)) - end) + before_each(function() + _primaryBalancer.is_affinitized = function (_) + return false + end + end) - it("returns false when alternative backends name does not match", function() - backend.trafficShapingPolicy.weight = 100 - balancer.sync_backend(backend) - _balancer.alternative_backends[1] = "nonExistingBackend" - assert.equal(false, balancer.route_to_alternative_balancer(_balancer)) - end) + it("returns false when no trafficShapingPolicy is set", function() + balancer.sync_backend(backend) + assert.equal(false, balancer.route_to_alternative_balancer(_primaryBalancer)) + end) - context("canary by weight", function() - it("returns true when weight is 100", function() + it("returns false when no alternative backends is set", function() backend.trafficShapingPolicy.weight = 100 balancer.sync_backend(backend) - assert.equal(true, balancer.route_to_alternative_balancer(_balancer)) + _primaryBalancer.alternative_backends = nil + assert.equal(false, balancer.route_to_alternative_balancer(_primaryBalancer)) end) - it("returns false when weight is 0", function() - backend.trafficShapingPolicy.weight = 0 + it("returns false when alternative backends name does not match", function() + backend.trafficShapingPolicy.weight = 100 balancer.sync_backend(backend) - assert.equal(false, balancer.route_to_alternative_balancer(_balancer)) + _primaryBalancer.alternative_backends[1] = "nonExistingBackend" + assert.equal(false, balancer.route_to_alternative_balancer(_primaryBalancer)) end) - end) - context("canary by cookie", function() - it("returns correct result for given cookies", function() - local test_patterns = { - { - case_title = "cookie_value is 'always'", - request_cookie_name = "canaryCookie", - request_cookie_value = "always", - expected_result = true, - }, - { - case_title = "cookie_value is 'never'", - request_cookie_name = "canaryCookie", - request_cookie_value = "never", - expected_result = false, - }, - { - case_title = "cookie_value is undefined", - request_cookie_name = "canaryCookie", - request_cookie_value = "foo", - expected_result = false, - }, - { - case_title = "cookie_name is undefined", - request_cookie_name = "foo", - request_cookie_value = "always", - expected_result = false - }, - } - for _, test_pattern in pairs(test_patterns) do - mock_ngx({ var = { - ["cookie_" .. test_pattern.request_cookie_name] = test_pattern.request_cookie_value, - request_uri = "/" - }}) - reset_balancer() - backend.trafficShapingPolicy.cookie = "canaryCookie" + describe("canary by weight", function() + it("returns true when weight is 100", function() + backend.trafficShapingPolicy.weight = 100 balancer.sync_backend(backend) - assert.message("\nTest data pattern: " .. test_pattern.case_title) - .equal(test_pattern.expected_result, balancer.route_to_alternative_balancer(_balancer)) - reset_ngx() - end + assert.equal(true, balancer.route_to_alternative_balancer(_primaryBalancer)) + end) + + it("returns false when weight is 0", function() + backend.trafficShapingPolicy.weight = 0 + balancer.sync_backend(backend) + assert.equal(false, balancer.route_to_alternative_balancer(_primaryBalancer)) + end) + end) + + describe("canary by cookie", function() + it("returns correct result for given cookies", function() + local test_patterns = { + { + case_title = "cookie_value is 'always'", + request_cookie_name = "canaryCookie", + request_cookie_value = "always", + expected_result = true, + }, + { + case_title = "cookie_value is 'never'", + request_cookie_name = "canaryCookie", + request_cookie_value = "never", + expected_result = false, + }, + { + case_title = "cookie_value is undefined", + request_cookie_name = "canaryCookie", + request_cookie_value = "foo", + expected_result = false, + }, + { + case_title = "cookie_name is undefined", + request_cookie_name = "foo", + request_cookie_value = "always", + expected_result = false + }, + } + for _, test_pattern in pairs(test_patterns) do + mock_ngx({ var = { + ["cookie_" .. test_pattern.request_cookie_name] = test_pattern.request_cookie_value, + request_uri = "/" + }}) + backend.trafficShapingPolicy.cookie = "canaryCookie" + balancer.sync_backend(backend) + assert.message("\nTest data pattern: " .. test_pattern.case_title) + .equal(test_pattern.expected_result, balancer.route_to_alternative_balancer(_primaryBalancer)) + reset_ngx() + end + end) end) + + describe("canary by header", function() + it("returns correct result for given headers", function() + local test_patterns = { + -- with no header value setting + { + case_title = "no custom header value and header value is 'always'", + header_name = "canaryHeader", + header_value = "", + request_header_name = "canaryHeader", + request_header_value = "always", + expected_result = true, + }, + { + case_title = "no custom header value and header value is 'never'", + header_name = "canaryHeader", + header_value = "", + request_header_name = "canaryHeader", + request_header_value = "never", + expected_result = false, + }, + { + case_title = "no custom header value and header value is undefined", + header_name = "canaryHeader", + header_value = "", + request_header_name = "canaryHeader", + request_header_value = "foo", + expected_result = false, + }, + { + case_title = "no custom header value and header name is undefined", + header_name = "canaryHeader", + header_value = "", + request_header_name = "foo", + request_header_value = "always", + expected_result = false, + }, + -- with header value setting + { + case_title = "custom header value is set and header value is 'always'", + header_name = "canaryHeader", + header_value = "foo", + request_header_name = "canaryHeader", + request_header_value = "always", + expected_result = false, + }, + { + case_title = "custom header value is set and header value match custom header value", + header_name = "canaryHeader", + header_value = "foo", + request_header_name = "canaryHeader", + request_header_value = "foo", + expected_result = true, + }, + { + case_title = "custom header value is set and header name is undefined", + header_name = "canaryHeader", + header_value = "foo", + request_header_name = "bar", + request_header_value = "foo", + expected_result = false + }, + } + + for _, test_pattern in pairs(test_patterns) do + mock_ngx({ var = { + ["http_" .. test_pattern.request_header_name] = test_pattern.request_header_value, + request_uri = "/" + }}) + backend.trafficShapingPolicy.header = test_pattern.header_name + backend.trafficShapingPolicy.headerValue = test_pattern.header_value + balancer.sync_backend(backend) + assert.message("\nTest data pattern: " .. test_pattern.case_title) + .equal(test_pattern.expected_result, balancer.route_to_alternative_balancer(_primaryBalancer)) + reset_ngx() + end + end) + end) + end) - context("canary by header", function() - it("returns correct result for given headers", function() - local test_patterns = { - -- with no header value setting - { - case_title = "no custom header value and header value is 'always'", - header_name = "canaryHeader", - header_value = "", - request_header_name = "canaryHeader", - request_header_value = "always", - expected_result = true, - }, - { - case_title = "no custom header value and header value is 'never'", - header_name = "canaryHeader", - header_value = "", - request_header_name = "canaryHeader", - request_header_value = "never", - expected_result = false, - }, - { - case_title = "no custom header value and header value is undefined", - header_name = "canaryHeader", - header_value = "", - request_header_name = "canaryHeader", - request_header_value = "foo", - expected_result = false, - }, - { - case_title = "no custom header value and header name is undefined", - header_name = "canaryHeader", - header_value = "", - request_header_name = "foo", - request_header_value = "always", - expected_result = false, - }, - -- with header value setting - { - case_title = "custom header value is set and header value is 'always'", - header_name = "canaryHeader", - header_value = "foo", - request_header_name = "canaryHeader", - request_header_value = "always", - expected_result = false, - }, - { - case_title = "custom header value is set and header value match custom header value", - header_name = "canaryHeader", - header_value = "foo", - request_header_name = "canaryHeader", - request_header_value = "foo", - expected_result = true, - }, - { - case_title = "custom header value is set and header name is undefined", - header_name = "canaryHeader", - header_value = "foo", - request_header_name = "bar", - request_header_value = "foo", - expected_result = false - }, - } + -- Affinitized request prefers backend it is affinitized to. + describe("affinitized", function() - for _, test_pattern in pairs(test_patterns) do - mock_ngx({ var = { - ["http_" .. test_pattern.request_header_name] = test_pattern.request_header_value, - request_uri = "/" - }}) - reset_balancer() - backend.trafficShapingPolicy.header = test_pattern.header_name - backend.trafficShapingPolicy.headerValue = test_pattern.header_value - balancer.sync_backend(backend) - assert.message("\nTest data pattern: " .. test_pattern.case_title) - .equal(test_pattern.expected_result, balancer.route_to_alternative_balancer(_balancer)) - reset_ngx() + before_each(function() + mock_ngx({ var = { request_uri = "/", proxy_upstream_name = backend.name } }) + balancer.sync_backend(backend) + end) + + it("returns false if request is affinitized to primary backend", function() + _primaryBalancer.is_affinitized = function (_) + return true end + + local alternativeBalancer = balancer.get_balancer_by_upstream_name(backend.name) + + local primarySpy = spy.on(_primaryBalancer, "is_affinitized") + local alternativeSpy = spy.on(alternativeBalancer, "is_affinitized") + + assert.is_false(balancer.route_to_alternative_balancer(_primaryBalancer)) + assert.spy(_primaryBalancer.is_affinitized).was_called() + assert.spy(alternativeBalancer.is_affinitized).was_not_called() end) + + it("returns true if request is affinitized to alternative backend", function() + _primaryBalancer.is_affinitized = function (_) + return false + end + + local alternativeBalancer = balancer.get_balancer_by_upstream_name(backend.name) + alternativeBalancer.is_affinitized = function (_) + return true + end + + local primarySpy = spy.on(_primaryBalancer, "is_affinitized") + local alternativeSpy = spy.on(alternativeBalancer, "is_affinitized") + + assert.is_true(balancer.route_to_alternative_balancer(_primaryBalancer)) + assert.spy(_primaryBalancer.is_affinitized).was_called() + assert.spy(alternativeBalancer.is_affinitized).was_called() + end) + end) end) @@ -432,10 +508,13 @@ describe("Balancer", function() }, } } - mock_ngx({ var = { proxy_upstream_name = "access-router-production-web-80" }, ctx = { } }) - ngx.shared.configuration_data:set("backends", cjson.encode(backends)) - reset_balancer() + + mock_ngx({ var = { proxy_upstream_name = "access-router-production-web-80" }, ctx = { } }, function() + ngx.shared.configuration_data:set("backends", cjson.encode(backends)) + end) + balancer.init_worker() + assert.not_equal(balancer.get_balancer(), nil) end) diff --git a/rootfs/etc/nginx/lua/test/util/split.lua b/rootfs/etc/nginx/lua/test/util/split.lua deleted file mode 100644 index 3d3a6d7e9a..0000000000 --- a/rootfs/etc/nginx/lua/test/util/split.lua +++ /dev/null @@ -1,15 +0,0 @@ -local split = require("util.split") - - -describe("split", function() - it("get_last_value", function() - for _, case in ipairs({ - {"127.0.0.1:26157 : 127.0.0.1:26158", "127.0.0.1:26158"}, - {"127.0.0.1:26157, 127.0.0.1:26158", "127.0.0.1:26158"}, - {"127.0.0.1:26158", "127.0.0.1:26158"}, - }) do - local last = split.get_last_value(case[1]) - assert.equal(case[2], last) - end - end) -end) diff --git a/rootfs/etc/nginx/lua/test/util/split_test.lua b/rootfs/etc/nginx/lua/test/util/split_test.lua new file mode 100644 index 0000000000..d81a92c2d4 --- /dev/null +++ b/rootfs/etc/nginx/lua/test/util/split_test.lua @@ -0,0 +1,57 @@ +local split = require("util.split") + +describe("split", function() + + describe("get_last_value", function() + it("splits value of an upstream variable and returns last value", function() + for _, case in ipairs({{"127.0.0.1:26157 : 127.0.0.1:26158", "127.0.0.1:26158"}, + {"127.0.0.1:26157, 127.0.0.1:26158", "127.0.0.1:26158"}, + {"127.0.0.1:26158", "127.0.0.1:26158"}}) do + local last = split.get_last_value(case[1]) + assert.equal(case[2], last) + end + end) + end) + + describe("split_string", function() + + it("returns empty array if input string is empty", function() + local splits, len = split.split_string("", ",") + assert.equal(0, len) + assert.is.truthy(splits) + end) + + it("returns empty array if input string is nil", function() + local splits, len = split.split_string(nil, ",") + assert.equal(0, len) + assert.is.truthy(splits) + end) + + it("returns empty array if delimiter is empty", function() + local splits, len = split.split_string("1,2", "") + assert.equal(0, len) + assert.is.truthy(splits) + end) + + it("returns empty array delimiter is nil", function() + local splits, len = split.split_string("1,2", nil) + assert.equal(0, len) + assert.is.truthy(splits) + end) + + it("returns array of 1 value if input string is not a list", function() + local splits, len = split.split_string("123", ",") + assert.equal(1, len) + assert.equal("123", splits[1]) + end) + + it("returns array of values extracted from the input string", function() + local splits, len = split.split_string("1,2,3", ",") + assert.equal(3, len) + assert.equal("1", splits[1]) + assert.equal("2", splits[2]) + assert.equal("3", splits[3]) + end) + + end) +end) diff --git a/rootfs/etc/nginx/lua/util/split.lua b/rootfs/etc/nginx/lua/util/split.lua index d5400ab577..63edf0900b 100644 --- a/rootfs/etc/nginx/lua/util/split.lua +++ b/rootfs/etc/nginx/lua/util/split.lua @@ -65,4 +65,19 @@ function _M.split_upstream_addr(addrs_str) return host_and_ports end +-- Splits string by delimiter. Returns array of parsed values and the length of the array. +function _M.split_string(what, delim) + local result = {} + local idx = 0 + + if what and delim and delim ~= "" then + for chunk in what:gmatch("([^" .. delim .. "]+)") do + idx = idx + 1 + result[idx] = chunk + end + end + + return result, idx +end + return _M