diff --git a/artifacts/flagger/crd.yaml b/artifacts/flagger/crd.yaml index 68522b863..ba49b3b8c 100644 --- a/artifacts/flagger/crd.yaml +++ b/artifacts/flagger/crd.yaml @@ -1158,10 +1158,32 @@ spec: primaryCookieName: description: CookieName is the key that will be used for the session affinity cookie. type: string + domain: + description: Domain defines the host to which the cookie will be sent. + type: string + httpOnly: + description: HttpOnly forbids JavaScript from accessing the cookie, for example, through the Document.cookie property. + type: boolean maxAge: description: MaxAge indicates the number of seconds until the session affinity cookie will expire. default: 86400 type: number + partitioned: + description: Partitioned indicates that the cookie should be stored using partitioned storage. + type: boolean + path: + description: Path indicates the path that must exist in the requested URL for the browser to send the Cookie header. + type: string + sameSite: + description: SameSite controls whether or not a cookie is sent with cross-site requests. + type: string + enum: + - Strict + - Lax + - None + secure: + description: "Secure indicates that the cookie is sent to the server only when a request is made with the https: scheme (except on localhost)" + type: boolean status: description: CanaryStatus defines the observed state of a canary. type: object diff --git a/charts/flagger/crds/crd.yaml b/charts/flagger/crds/crd.yaml index 68522b863..ba49b3b8c 100644 --- a/charts/flagger/crds/crd.yaml +++ b/charts/flagger/crds/crd.yaml @@ -1158,10 +1158,32 @@ spec: primaryCookieName: description: CookieName is the key that will be used for the session affinity cookie. type: string + domain: + description: Domain defines the host to which the cookie will be sent. + type: string + httpOnly: + description: HttpOnly forbids JavaScript from accessing the cookie, for example, through the Document.cookie property. + type: boolean maxAge: description: MaxAge indicates the number of seconds until the session affinity cookie will expire. default: 86400 type: number + partitioned: + description: Partitioned indicates that the cookie should be stored using partitioned storage. + type: boolean + path: + description: Path indicates the path that must exist in the requested URL for the browser to send the Cookie header. + type: string + sameSite: + description: SameSite controls whether or not a cookie is sent with cross-site requests. + type: string + enum: + - Strict + - Lax + - None + secure: + description: "Secure indicates that the cookie is sent to the server only when a request is made with the https: scheme (except on localhost)" + type: boolean status: description: CanaryStatus defines the observed state of a canary. type: object diff --git a/docs/gitbook/usage/deployment-strategies.md b/docs/gitbook/usage/deployment-strategies.md index aba99d015..5dfdc2966 100644 --- a/docs/gitbook/usage/deployment-strategies.md +++ b/docs/gitbook/usage/deployment-strategies.md @@ -494,3 +494,38 @@ then all subsequent requests will be routed to the same until the next step star value is generated which is then included in the headers of responses from the primary workload. This allows for weighted traffic routing to happen while ensuring that users don't ever switch back to the primary deployment from the canary deployment during a Canary analysis. + +### Configuring additional cookie attributes + +Depending on your use case, you may neet to set additional [cookie attributes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes) in order for your application to route requests correctly. +You may set the following attributes: + +```yaml + analysis: + # schedule interval (default 60s) + interval: 1m + sessionAffinity: + # name of the cookie used + cookieName: flagger-cookie + # max age of the cookie (in seconds) + # optional; defaults to 86400 + maxAge: 21600 + # defines the host to which the cookie will be sent. + # optional + domain: fluxcd.io + # forbids JavaScript from accessing the cookie, for example, through the Document.cookie property. + # optional + httpOnly: true + # indicates that the cookie should be stored using partitioned storage. + # optional + partitioned: true + # indicates the path that must exist in the requested URL for the browser to send the Cookie header. + # optional + path: /flagger + # controls whether or not a cookie is sent with cross-site requests. + # optional; valid values are Strict, Lax or None + sameSite: Strict + # indicates that the cookie is sent to the server only when a request is made with the https: scheme (except on localhost) + # optional + secure: true +``` diff --git a/kustomize/base/flagger/crd.yaml b/kustomize/base/flagger/crd.yaml index 68522b863..ba49b3b8c 100644 --- a/kustomize/base/flagger/crd.yaml +++ b/kustomize/base/flagger/crd.yaml @@ -1158,10 +1158,32 @@ spec: primaryCookieName: description: CookieName is the key that will be used for the session affinity cookie. type: string + domain: + description: Domain defines the host to which the cookie will be sent. + type: string + httpOnly: + description: HttpOnly forbids JavaScript from accessing the cookie, for example, through the Document.cookie property. + type: boolean maxAge: description: MaxAge indicates the number of seconds until the session affinity cookie will expire. default: 86400 type: number + partitioned: + description: Partitioned indicates that the cookie should be stored using partitioned storage. + type: boolean + path: + description: Path indicates the path that must exist in the requested URL for the browser to send the Cookie header. + type: string + sameSite: + description: SameSite controls whether or not a cookie is sent with cross-site requests. + type: string + enum: + - Strict + - Lax + - None + secure: + description: "Secure indicates that the cookie is sent to the server only when a request is made with the https: scheme (except on localhost)" + type: boolean status: description: CanaryStatus defines the observed state of a canary. type: object diff --git a/pkg/apis/flagger/v1beta1/canary.go b/pkg/apis/flagger/v1beta1/canary.go index aae9ccd88..e8628f6a0 100644 --- a/pkg/apis/flagger/v1beta1/canary.go +++ b/pkg/apis/flagger/v1beta1/canary.go @@ -297,11 +297,30 @@ type CanaryAnalysis struct { type SessionAffinity struct { // CookieName is the key that will be used for the session affinity cookie. CookieName string `json:"cookieName,omitempty"` - // MaxAge indicates the number of seconds until the session affinity cookie will expire. // ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes + // Domain defines the host to which the cookie will be sent. + // +optional + Domain string `json:"domain,omitempty"` + // HttpOnly forbids JavaScript from accessing the cookie, for example, through the Document.cookie property. + // +optional + HttpOnly bool `json:"httpOnly,omitempty"` + // MaxAge indicates the number of seconds until the session affinity cookie will expire. // The default value is 86,400 seconds, i.e. a day. // +optional MaxAge int `json:"maxAge,omitempty"` + // Partitioned indicates that the cookie should be stored using partitioned storage. + // +optional + Partitioned bool `json:"partitioned,omitempty"` + // Path indicates the path that must exist in the requested URL for the browser to send the Cookie header. + // +optional + Path string `json:"path,omitempty"` + // SameSite controls whether or not a cookie is sent with cross-site requests. + // +optional + // +kubebuilder:validation:Enum=Strict;Lax;None + SameSite string `json:"sameSite,omitempty"` + // Secure indicates that the cookie is sent to the server only when a request is made with the https: scheme (except on localhost) + // +optional + Secure bool `json:"secure,omitempty"` // PrimaryCookieName is the key that will be used for the primary session affinity cookie. // +optional PrimaryCookieName string `json:"primaryCookieName,omitempty"` @@ -668,3 +687,36 @@ func (c *Canary) DeploymentStrategy() string { // Canary Release: default (has maxWeight, stepWeight, or stepWeights) return DeploymentStrategyCanary } + +// BuildCookie returns the cookie that should be used as the value of a Set-Cookie header +func (s *SessionAffinity) BuildCookie(cookieName string) string { + cookie := fmt.Sprintf("%s; %s=%d", cookieName, "Max-Age", + s.GetMaxAge(), + ) + + if s.Domain != "" { + cookie += fmt.Sprintf("; %s=%s", "Domain", s.Domain) + } + + if s.HttpOnly { + cookie += fmt.Sprintf("; %s", "HttpOnly") + } + + if s.Partitioned { + cookie += fmt.Sprintf("; %s", "Partitioned") + } + + if s.Path != "" { + cookie += fmt.Sprintf("; %s=%s", "Path", s.Path) + } + + if s.SameSite != "" { + cookie += fmt.Sprintf("; %s=%s", "SameSite", s.SameSite) + } + + if s.Secure { + cookie += fmt.Sprintf("; %s", "Secure") + } + + return cookie +} diff --git a/pkg/router/gateway_api.go b/pkg/router/gateway_api.go index be0cfef8a..161bcc3fd 100644 --- a/pkg/router/gateway_api.go +++ b/pkg/router/gateway_api.go @@ -482,10 +482,8 @@ func (gwr *GatewayAPIRouter) getSessionAffinityRouteRules(canary *flaggerv1.Cana ResponseHeaderModifier: &v1.HTTPHeaderFilter{ Add: []v1.HTTPHeader{ { - Name: setCookieHeader, - Value: fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, - canary.Spec.Analysis.SessionAffinity.GetMaxAge(), - ), + Name: setCookieHeader, + Value: canary.Spec.Analysis.SessionAffinity.BuildCookie(canary.Status.SessionAffinityCookie), }, }, }, diff --git a/pkg/router/gateway_api_test.go b/pkg/router/gateway_api_test.go index 425034a9f..0278a50db 100644 --- a/pkg/router/gateway_api_test.go +++ b/pkg/router/gateway_api_test.go @@ -99,8 +99,14 @@ func TestGatewayAPIRouter_Routes(t *testing.T) { cookieKey := "flagger-cookie" // enable session affinity and start canary run canary.Spec.Analysis.SessionAffinity = &flaggerv1.SessionAffinity{ - CookieName: cookieKey, - MaxAge: 300, + CookieName: cookieKey, + Domain: "flagger.app", + HttpOnly: true, + MaxAge: 300, + Partitioned: true, + Path: "/app", + SameSite: "Strict", + Secure: true, } _, pSvcName, cSvcName := canary.GetServiceNames() @@ -137,10 +143,18 @@ func TestGatewayAPIRouter_Routes(t *testing.T) { if string(backendRef.Name) == cSvcName { found = true filter := backendRef.Filters[0] + val := filter.ResponseHeaderModifier.Add[0].Value assert.Equal(t, filter.Type, v1.HTTPRouteFilterResponseHeaderModifier) assert.NotNil(t, filter.ResponseHeaderModifier) assert.Equal(t, string(filter.ResponseHeaderModifier.Add[0].Name), setCookieHeader) - assert.Equal(t, filter.ResponseHeaderModifier.Add[0].Value, fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, 300)) + assert.True(t, strings.HasPrefix(val, cookieKey)) + assert.True(t, strings.Contains(val, "Domain=flagger.app")) + assert.True(t, strings.Contains(val, "HttpOnly")) + assert.True(t, strings.Contains(val, "Max-Age=300")) + assert.True(t, strings.Contains(val, "Partitioned")) + assert.True(t, strings.Contains(val, "Path=/app")) + assert.True(t, strings.Contains(val, "SameSite=Strict")) + assert.True(t, strings.Contains(val, "Secure")) assert.Equal(t, *backendRef.Weight, int32(10)) } if string(backendRef.Name) == pSvcName { @@ -193,10 +207,18 @@ func TestGatewayAPIRouter_Routes(t *testing.T) { if string(backendRef.Name) == cSvcName { found = true filter := backendRef.Filters[0] + val := filter.ResponseHeaderModifier.Add[0].Value assert.Equal(t, filter.Type, v1.HTTPRouteFilterResponseHeaderModifier) assert.NotNil(t, filter.ResponseHeaderModifier) assert.Equal(t, string(filter.ResponseHeaderModifier.Add[0].Name), setCookieHeader) - assert.Equal(t, filter.ResponseHeaderModifier.Add[0].Value, fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, 300)) + assert.True(t, strings.HasPrefix(val, cookieKey)) + assert.True(t, strings.Contains(val, "Domain=flagger.app")) + assert.True(t, strings.Contains(val, "HttpOnly")) + assert.True(t, strings.Contains(val, "Max-Age=300")) + assert.True(t, strings.Contains(val, "Partitioned")) + assert.True(t, strings.Contains(val, "Path=/app")) + assert.True(t, strings.Contains(val, "SameSite=Strict")) + assert.True(t, strings.Contains(val, "Secure")) assert.Equal(t, *backendRef.Weight, int32(50)) } diff --git a/pkg/router/gateway_api_v1beta1.go b/pkg/router/gateway_api_v1beta1.go index da65e359c..198fe991b 100644 --- a/pkg/router/gateway_api_v1beta1.go +++ b/pkg/router/gateway_api_v1beta1.go @@ -423,10 +423,8 @@ func (gwr *GatewayAPIV1Beta1Router) getSessionAffinityRouteRules(canary *flagger ResponseHeaderModifier: &v1beta1.HTTPHeaderFilter{ Add: []v1beta1.HTTPHeader{ { - Name: setCookieHeader, - Value: fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, - canary.Spec.Analysis.SessionAffinity.GetMaxAge(), - ), + Name: setCookieHeader, + Value: canary.Spec.Analysis.SessionAffinity.BuildCookie(canary.Status.SessionAffinityCookie), }, }, }, diff --git a/pkg/router/gateway_api_v1beta1_test.go b/pkg/router/gateway_api_v1beta1_test.go index 9ffeda416..e14b41a75 100644 --- a/pkg/router/gateway_api_v1beta1_test.go +++ b/pkg/router/gateway_api_v1beta1_test.go @@ -96,8 +96,14 @@ func TestGatewayAPIV1Beta1Router_Routes(t *testing.T) { cookieKey := "flagger-cookie" // enable session affinity and start canary run canary.Spec.Analysis.SessionAffinity = &flaggerv1.SessionAffinity{ - CookieName: cookieKey, - MaxAge: 300, + CookieName: cookieKey, + Domain: "flagger.app", + HttpOnly: true, + MaxAge: 300, + Partitioned: true, + Path: "/app", + SameSite: "Strict", + Secure: true, } _, pSvcName, cSvcName := canary.GetServiceNames() @@ -133,10 +139,18 @@ func TestGatewayAPIV1Beta1Router_Routes(t *testing.T) { if string(backendRef.Name) == cSvcName { found = true filter := backendRef.Filters[0] + val := filter.ResponseHeaderModifier.Add[0].Value assert.Equal(t, filter.Type, v1beta1.HTTPRouteFilterResponseHeaderModifier) assert.NotNil(t, filter.ResponseHeaderModifier) assert.Equal(t, string(filter.ResponseHeaderModifier.Add[0].Name), setCookieHeader) - assert.Equal(t, filter.ResponseHeaderModifier.Add[0].Value, fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, 300)) + assert.True(t, strings.HasPrefix(val, cookieKey)) + assert.True(t, strings.Contains(val, "Domain=flagger.app")) + assert.True(t, strings.Contains(val, "HttpOnly")) + assert.True(t, strings.Contains(val, "Max-Age=300")) + assert.True(t, strings.Contains(val, "Partitioned")) + assert.True(t, strings.Contains(val, "Path=/app")) + assert.True(t, strings.Contains(val, "SameSite=Strict")) + assert.True(t, strings.Contains(val, "Secure")) assert.Equal(t, *backendRef.Weight, int32(10)) } if string(backendRef.Name) == pSvcName { @@ -189,10 +203,18 @@ func TestGatewayAPIV1Beta1Router_Routes(t *testing.T) { if string(backendRef.Name) == cSvcName { found = true filter := backendRef.Filters[0] + val := filter.ResponseHeaderModifier.Add[0].Value assert.Equal(t, filter.Type, v1beta1.HTTPRouteFilterResponseHeaderModifier) assert.NotNil(t, filter.ResponseHeaderModifier) assert.Equal(t, string(filter.ResponseHeaderModifier.Add[0].Name), setCookieHeader) - assert.Equal(t, filter.ResponseHeaderModifier.Add[0].Value, fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, 300)) + assert.True(t, strings.HasPrefix(val, cookieKey)) + assert.True(t, strings.Contains(val, "Domain=flagger.app")) + assert.True(t, strings.Contains(val, "HttpOnly")) + assert.True(t, strings.Contains(val, "Max-Age=300")) + assert.True(t, strings.Contains(val, "Partitioned")) + assert.True(t, strings.Contains(val, "Path=/app")) + assert.True(t, strings.Contains(val, "SameSite=Strict")) + assert.True(t, strings.Contains(val, "Secure")) assert.Equal(t, *backendRef.Weight, int32(50)) } diff --git a/pkg/router/istio.go b/pkg/router/istio.go index 75b3701a7..c588847f8 100644 --- a/pkg/router/istio.go +++ b/pkg/router/istio.go @@ -532,9 +532,7 @@ func (ir *IstioRouter) SetRoutes( } } routeDest.Headers.Response.Add = map[string]string{ - setCookieHeader: fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, - canary.Spec.Analysis.SessionAffinity.GetMaxAge(), - ), + setCookieHeader: canary.Spec.Analysis.SessionAffinity.BuildCookie(canary.Status.SessionAffinityCookie), } } weightedRoute.Route[i] = routeDest diff --git a/pkg/router/istio_test.go b/pkg/router/istio_test.go index 9bebeb0e2..fb2ce0032 100644 --- a/pkg/router/istio_test.go +++ b/pkg/router/istio_test.go @@ -190,8 +190,14 @@ func TestIstioRouter_SetRoutes(t *testing.T) { cookieKey := "flagger-cookie" // enable session affinity and start canary run canary.Spec.Analysis.SessionAffinity = &v1beta1.SessionAffinity{ - CookieName: cookieKey, - MaxAge: 300, + CookieName: cookieKey, + Domain: "flagger.app", + HttpOnly: true, + MaxAge: 300, + Partitioned: true, + Path: "/app", + SameSite: "Strict", + Secure: true, } err := router.SetRoutes(canary, 0, 10, false) @@ -231,7 +237,13 @@ func TestIstioRouter_SetRoutes(t *testing.T) { val, ok := routeDest.Headers.Response.Add[setCookieHeader] assert.True(t, ok) assert.True(t, strings.HasPrefix(val, cookieKey)) + assert.True(t, strings.Contains(val, "Domain=flagger.app")) + assert.True(t, strings.Contains(val, "HttpOnly")) assert.True(t, strings.Contains(val, "Max-Age=300")) + assert.True(t, strings.Contains(val, "Partitioned")) + assert.True(t, strings.Contains(val, "Path=/app")) + assert.True(t, strings.Contains(val, "SameSite=Strict")) + assert.True(t, strings.Contains(val, "Secure")) } } assert.True(t, strings.HasPrefix(canary.Status.SessionAffinityCookie, cookieKey)) @@ -286,7 +298,13 @@ func TestIstioRouter_SetRoutes(t *testing.T) { val, ok := routeDest.Headers.Response.Add[setCookieHeader] assert.True(t, ok) assert.True(t, strings.HasPrefix(val, cookieKey)) + assert.True(t, strings.Contains(val, "Domain=flagger.app")) + assert.True(t, strings.Contains(val, "HttpOnly")) assert.True(t, strings.Contains(val, "Max-Age=300")) + assert.True(t, strings.Contains(val, "Partitioned")) + assert.True(t, strings.Contains(val, "Path=/app")) + assert.True(t, strings.Contains(val, "SameSite=Strict")) + assert.True(t, strings.Contains(val, "Secure")) } } assert.True(t, strings.HasPrefix(canary.Status.SessionAffinityCookie, cookieKey))