Merge pull request #1826 from renatovassaomb/rv/add-cookie-attributes

Feat: Add Support for Cookie Attributes in Session Affinity
This commit is contained in:
Stefan Prodan
2025-10-15 00:33:38 +03:00
committed by GitHub
11 changed files with 231 additions and 22 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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

View File

@@ -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
}

View File

@@ -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),
},
},
},

View File

@@ -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))
}

View File

@@ -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),
},
},
},

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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))