feat: add support for primary backend cookies in session affinity

Signed-off-by: Sanskar Jaiswal <jaiswalsanskar078@gmail.com>
This commit is contained in:
Sanskar Jaiswal
2025-03-24 13:08:27 +05:30
parent 50d1331ba6
commit 1dc270c2e6
10 changed files with 375 additions and 133 deletions
+3
View File
@@ -1155,6 +1155,9 @@ spec:
cookieName:
description: CookieName is the key that will be used for the session affinity cookie.
type: string
primaryCookieName:
description: CookieName is the key that will be used for the session affinity cookie.
type: string
maxAge:
description: MaxAge indicates the number of seconds until the session affinity cookie will expire.
default: 86400
+3
View File
@@ -1155,6 +1155,9 @@ spec:
cookieName:
description: CookieName is the key that will be used for the session affinity cookie.
type: string
primaryCookieName:
description: CookieName is the key that will be used for the session affinity cookie.
type: string
maxAge:
description: MaxAge indicates the number of seconds until the session affinity cookie will expire.
default: 86400
+3
View File
@@ -1155,6 +1155,9 @@ spec:
cookieName:
description: CookieName is the key that will be used for the session affinity cookie.
type: string
primaryCookieName:
description: CookieName is the key that will be used for the session affinity cookie.
type: string
maxAge:
description: MaxAge indicates the number of seconds until the session affinity cookie will expire.
default: 86400
+3
View File
@@ -295,6 +295,9 @@ type SessionAffinity struct {
// The default value is 86,400 seconds, i.e. a day.
// +optional
MaxAge int `json:"maxAge,omitempty"`
// PrimaryCookieName is the key that will be used for the primary session affinity cookie.
// +optional
PrimaryCookieName string `json:"primaryCookieName,omitempty"`
}
// CanaryMetric holds the reference to metrics used for canary analysis
+13
View File
@@ -337,6 +337,9 @@ func (c *Controller) verifyCanary(canary *flaggerv1.Canary) error {
if err := verifyKnativeCanary(canary); err != nil {
return err
}
if err := verifySessionAffinity(canary); err != nil {
return err
}
return nil
}
@@ -378,6 +381,16 @@ func verifyKnativeCanary(canary *flaggerv1.Canary) error {
return nil
}
func verifySessionAffinity(canary *flaggerv1.Canary) error {
if canary.Spec.Analysis.SessionAffinity != nil {
if canary.Spec.Analysis.SessionAffinity.CookieName == canary.Spec.Analysis.SessionAffinity.PrimaryCookieName {
return fmt.Errorf("can't use the same cookie name for both primary and cookie name; please update them to be different")
}
}
return nil
}
func checkCustomResourceType(obj interface{}, logger *zap.SugaredLogger) (flaggerv1.Canary, bool) {
var roll *flaggerv1.Canary
var ok bool
+24
View File
@@ -26,6 +26,7 @@ func TestController_verifyCanary(t *testing.T) {
Name: "upstream",
Namespace: "test",
},
Analysis: &flaggerv1.CanaryAnalysis{},
},
},
wantErr: true,
@@ -42,6 +43,7 @@ func TestController_verifyCanary(t *testing.T) {
Name: "upstream",
Namespace: "default",
},
Analysis: &flaggerv1.CanaryAnalysis{},
},
},
wantErr: false,
@@ -103,6 +105,7 @@ func TestController_verifyCanary(t *testing.T) {
Kind: "Deployment",
Name: "podinfo",
},
Analysis: &flaggerv1.CanaryAnalysis{},
},
},
wantErr: true,
@@ -121,6 +124,7 @@ func TestController_verifyCanary(t *testing.T) {
APIVersion: "serving.knative.dev/v1",
Name: "podinfo",
},
Analysis: &flaggerv1.CanaryAnalysis{},
},
},
wantErr: true,
@@ -140,6 +144,7 @@ func TestController_verifyCanary(t *testing.T) {
APIVersion: "serving.knative.dev/v1",
Name: "podinfo",
},
Analysis: &flaggerv1.CanaryAnalysis{},
},
},
wantErr: true,
@@ -158,10 +163,29 @@ func TestController_verifyCanary(t *testing.T) {
APIVersion: "serving.knative.dev/v1",
Name: "podinfo",
},
Analysis: &flaggerv1.CanaryAnalysis{},
},
},
wantErr: false,
},
{
name: "session affinity with same cookie names should return an error",
canary: flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: "cd-1",
Namespace: "default",
},
Spec: flaggerv1.CanarySpec{
Analysis: &flaggerv1.CanaryAnalysis{
SessionAffinity: &flaggerv1.SessionAffinity{
CookieName: "smth",
PrimaryCookieName: "smth",
},
},
},
},
wantErr: true,
},
}
ctrl := &Controller{
+110 -34
View File
@@ -22,6 +22,7 @@ import (
"reflect"
"slices"
"strings"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@@ -190,20 +191,26 @@ func (gwr *GatewayAPIRouter) Reconcile(canary *flaggerv1.Canary) error {
cmpopts.IgnoreFields(v1.BackendRef{}, "Weight"),
cmpopts.EquateEmpty(),
}
if canary.Spec.Analysis.SessionAffinity != nil {
ignoreRoute := cmpopts.IgnoreSliceElements(func(r v1.HTTPRouteRule) bool {
// Ignore the rule that does sticky routing, i.e. matches against the `Cookie` header.
for _, match := range r.Matches {
for _, headerMatch := range match.Headers {
if *headerMatch.Type == headerMatchRegex && headerMatch.Name == cookieHeader &&
strings.Contains(headerMatch.Value, canary.Spec.Analysis.SessionAffinity.CookieName) {
return true
ignoreCookieRouteFunc := func(name string) func(r v1.HTTPRouteRule) bool {
return func(r v1.HTTPRouteRule) bool {
// Ignore the rule that does sticky routing, i.e. matches against the `Cookie` header.
for _, match := range r.Matches {
for _, headerMatch := range match.Headers {
if *headerMatch.Type == headerMatchRegex && headerMatch.Name == cookieHeader &&
strings.Contains(headerMatch.Value, name) {
return true
}
}
}
return false
}
return false
})
ignoreCmpOptions = append(ignoreCmpOptions, ignoreRoute)
}
ignoreCanaryRoute := cmpopts.IgnoreSliceElements(ignoreCookieRouteFunc(canary.Spec.Analysis.SessionAffinity.CookieName))
ignorePrimaryRoute := cmpopts.IgnoreSliceElements(ignoreCookieRouteFunc(canary.Spec.Analysis.SessionAffinity.PrimaryCookieName))
ignoreCmpOptions = append(ignoreCmpOptions, ignoreCanaryRoute, ignorePrimaryRoute)
// Ignore backend specific filters, since we use that to insert the `Set-Cookie` header in responses.
ignoreCmpOptions = append(ignoreCmpOptions, cmpopts.IgnoreFields(v1.HTTPBackendRef{}, "Filters"))
}
@@ -439,42 +446,76 @@ func (gwr *GatewayAPIRouter) Finalize(_ *flaggerv1.Canary) error {
return nil
}
func getBackendByServiceName(rule *v1.HTTPRouteRule, svcName string) *v1.HTTPBackendRef {
for i, backendRef := range rule.BackendRefs {
if string(backendRef.BackendObjectReference.Name) == svcName {
return &rule.BackendRefs[i]
}
}
return nil
}
// getSessionAffinityRouteRules returns the HTTPRouteRule objects required to perform
// session affinity based Canary releases.
func (gwr *GatewayAPIRouter) getSessionAffinityRouteRules(canary *flaggerv1.Canary, canaryWeight int,
weightedRouteRule *v1.HTTPRouteRule) ([]v1.HTTPRouteRule, error) {
_, primarySvcName, canarySvcName := canary.GetServiceNames()
stickyRouteRule := *weightedRouteRule
stickyCanaryRouteRule := *weightedRouteRule
stickyPrimaryRouteRule := *weightedRouteRule
// If a canary run is active, we want all responses corresponding to requests hitting the canary deployment
// (due to weighted routing) to include a `Set-Cookie` header. All requests that have the `Cookie` header
// and match the value of the `Set-Cookie` header will be routed to the canary deployment.
if canaryWeight != 0 {
// if the status doesn't have the canary cookie, then generate a new canary cookie.
if canary.Status.SessionAffinityCookie == "" {
canary.Status.SessionAffinityCookie = fmt.Sprintf("%s=%s", canary.Spec.Analysis.SessionAffinity.CookieName, randSeq())
}
primaryCookie := fmt.Sprintf("%s=%s", canary.Spec.Analysis.SessionAffinity.PrimaryCookieName, randSeq())
// Add `Set-Cookie` header modifier to the primary backend in the weighted routing rule.
for i, backendRef := range weightedRouteRule.BackendRefs {
if string(backendRef.BackendObjectReference.Name) == canarySvcName {
backendRef.Filters = append(backendRef.Filters, v1.HTTPRouteFilter{
Type: v1.HTTPRouteFilterResponseHeaderModifier,
ResponseHeaderModifier: &v1.HTTPHeaderFilter{
Add: []v1.HTTPHeader{
{
Name: setCookieHeader,
Value: fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr,
canary.Spec.Analysis.SessionAffinity.GetMaxAge(),
),
},
// add response modifier to the canary backend ref in the rule that does weighted routing
// to include the canary cookie.
canaryBackendRef := getBackendByServiceName(weightedRouteRule, canarySvcName)
canaryBackendRef.Filters = append(canaryBackendRef.Filters, v1.HTTPRouteFilter{
Type: v1.HTTPRouteFilterResponseHeaderModifier,
ResponseHeaderModifier: &v1.HTTPHeaderFilter{
Add: []v1.HTTPHeader{
{
Name: setCookieHeader,
Value: fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr,
canary.Spec.Analysis.SessionAffinity.GetMaxAge(),
),
},
},
},
})
// add response modifier to the primary backend ref in the rule that does weighted routing
// to include the primary cookie, only if a primary cookie name has been specified.
if canary.Spec.Analysis.SessionAffinity.PrimaryCookieName != "" {
primaryBackendRef := getBackendByServiceName(weightedRouteRule, primarySvcName)
interval, err := time.ParseDuration(canary.Spec.Analysis.Interval)
if err != nil {
return nil, fmt.Errorf("failed to parse canary interval: %w", err)
}
primaryBackendRef.Filters = append(primaryBackendRef.Filters, v1.HTTPRouteFilter{
Type: v1.HTTPRouteFilterResponseHeaderModifier,
ResponseHeaderModifier: &v1.HTTPHeaderFilter{
Add: []v1.HTTPHeader{
{
Name: setCookieHeader,
Value: fmt.Sprintf("%s; %s=%d", primaryCookie, maxAgeAttr,
int(interval.Seconds()),
),
},
},
})
}
weightedRouteRule.BackendRefs[i] = backendRef
},
})
}
// Add `Cookie` header matcher to the sticky routing rule.
// configure the sticky canary rule to match against requests that match against the
// canary cookie and send them to the canary backend.
cookieKeyAndVal := strings.Split(canary.Status.SessionAffinityCookie, "=")
regexMatchType := v1.HeaderMatchRegularExpression
cookieMatch := v1.HTTPRouteMatch{
@@ -493,8 +534,8 @@ func (gwr *GatewayAPIRouter) getSessionAffinityRouteRules(canary *flaggerv1.Cana
}
mergedMatches := gwr.mergeMatchConditions([]v1.HTTPRouteMatch{cookieMatch}, svcMatches)
stickyRouteRule.Matches = mergedMatches
stickyRouteRule.BackendRefs = []v1.HTTPBackendRef{
stickyCanaryRouteRule.Matches = mergedMatches
stickyCanaryRouteRule.BackendRefs = []v1.HTTPBackendRef{
{
BackendRef: gwr.makeBackendRef(primarySvcName, 0, canary.Spec.Service.Port),
},
@@ -502,6 +543,42 @@ func (gwr *GatewayAPIRouter) getSessionAffinityRouteRules(canary *flaggerv1.Cana
BackendRef: gwr.makeBackendRef(canarySvcName, 100, canary.Spec.Service.Port),
},
}
// add a sticky primary rule to match against requests that match against the
// primary cookie and send them to the primary backend, only if a primary cookie name has
// been specified.
if canary.Spec.Analysis.SessionAffinity.PrimaryCookieName != "" {
cookieKeyAndVal = strings.Split(primaryCookie, "=")
regexMatchType = v1.HeaderMatchRegularExpression
primaryCookieMatch := v1.HTTPRouteMatch{
Headers: []v1.HTTPHeaderMatch{
{
Type: &regexMatchType,
Name: cookieHeader,
Value: fmt.Sprintf(".*%s.*%s.*", cookieKeyAndVal[0], cookieKeyAndVal[1]),
},
},
}
svcMatches, err = gwr.mapRouteMatches(canary.Spec.Service.Match)
if err != nil {
return nil, err
}
mergedMatches = gwr.mergeMatchConditions([]v1.HTTPRouteMatch{primaryCookieMatch}, svcMatches)
stickyPrimaryRouteRule.Matches = mergedMatches
stickyPrimaryRouteRule.BackendRefs = []v1.HTTPBackendRef{
{
BackendRef: gwr.makeBackendRef(primarySvcName, 100, canary.Spec.Service.Port),
},
{
BackendRef: gwr.makeBackendRef(canarySvcName, 0, canary.Spec.Service.Port),
},
}
return []v1.HTTPRouteRule{stickyCanaryRouteRule, stickyPrimaryRouteRule, *weightedRouteRule}, nil
}
return []v1.HTTPRouteRule{stickyCanaryRouteRule, *weightedRouteRule}, nil
} else {
// If canary weight is 0 and SessionAffinityCookie is non-blank, then it belongs to a previous canary run.
if canary.Status.SessionAffinityCookie != "" {
@@ -524,9 +601,9 @@ func (gwr *GatewayAPIRouter) getSessionAffinityRouteRules(canary *flaggerv1.Cana
}
svcMatches, _ := gwr.mapRouteMatches(canary.Spec.Service.Match)
mergedMatches := gwr.mergeMatchConditions([]v1.HTTPRouteMatch{cookieMatch}, svcMatches)
stickyRouteRule.Matches = mergedMatches
stickyCanaryRouteRule.Matches = mergedMatches
stickyRouteRule.Filters = append(stickyRouteRule.Filters, v1.HTTPRouteFilter{
stickyCanaryRouteRule.Filters = append(stickyCanaryRouteRule.Filters, v1.HTTPRouteFilter{
Type: v1.HTTPRouteFilterResponseHeaderModifier,
ResponseHeaderModifier: &v1.HTTPHeaderFilter{
Add: []v1.HTTPHeader{
@@ -540,9 +617,8 @@ func (gwr *GatewayAPIRouter) getSessionAffinityRouteRules(canary *flaggerv1.Cana
}
canary.Status.SessionAffinityCookie = ""
return []v1.HTTPRouteRule{stickyCanaryRouteRule, *weightedRouteRule}, nil
}
return []v1.HTTPRouteRule{stickyRouteRule, *weightedRouteRule}, nil
}
func (gwr *GatewayAPIRouter) mapRouteMatches(requestMatches []istiov1beta1.HTTPMatchRequest) ([]v1.HTTPRouteMatch, error) {
+178 -64
View File
@@ -21,6 +21,7 @@ import (
"fmt"
"strings"
"testing"
"time"
flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
v1 "github.com/fluxcd/flagger/pkg/apis/gatewayapi/v1"
@@ -104,6 +105,7 @@ func TestGatewayAPIRouter_Routes(t *testing.T) {
_, pSvcName, cSvcName := canary.GetServiceNames()
err := router.SetRoutes(canary, 90, 10, false)
require.NoError(t, err)
hr, err := mocks.meshClient.GatewayapiV1().HTTPRoutes("default").Get(context.TODO(), "podinfo", metav1.GetOptions{})
require.NoError(t, err)
@@ -290,80 +292,192 @@ func TestGatewayAPIRouter_Routes(t *testing.T) {
}
func TestGatewayAPIRouter_getSessionAffinityRouteRules(t *testing.T) {
canary := newTestGatewayAPICanary()
mocks := newFixture(canary)
cookieKey := "flagger-cookie"
canary.Spec.Analysis.SessionAffinity = &flaggerv1.SessionAffinity{
CookieName: cookieKey,
MaxAge: 300,
}
t.Run("without primary cookie", func(t *testing.T) {
canary := newTestGatewayAPICanary()
mocks := newFixture(canary)
cookieKey := "flagger-cookie"
canary.Spec.Analysis.SessionAffinity = &flaggerv1.SessionAffinity{
CookieName: cookieKey,
MaxAge: 300,
}
router := &GatewayAPIRouter{
gatewayAPIClient: mocks.meshClient,
kubeClient: mocks.kubeClient,
logger: mocks.logger,
}
_, pSvcName, cSvcName := canary.GetServiceNames()
weightedRouteRule := &v1.HTTPRouteRule{
BackendRefs: []v1.HTTPBackendRef{
{
BackendRef: router.makeBackendRef(pSvcName, initialPrimaryWeight, canary.Spec.Service.Port),
router := &GatewayAPIRouter{
gatewayAPIClient: mocks.meshClient,
kubeClient: mocks.kubeClient,
logger: mocks.logger,
}
_, pSvcName, cSvcName := canary.GetServiceNames()
weightedRouteRule := &v1.HTTPRouteRule{
BackendRefs: []v1.HTTPBackendRef{
{
BackendRef: router.makeBackendRef(pSvcName, initialPrimaryWeight, canary.Spec.Service.Port),
},
{
BackendRef: router.makeBackendRef(cSvcName, initialCanaryWeight, canary.Spec.Service.Port),
},
},
{
BackendRef: router.makeBackendRef(cSvcName, initialCanaryWeight, canary.Spec.Service.Port),
}
rules, err := router.getSessionAffinityRouteRules(canary, 10, weightedRouteRule)
require.NoError(t, err)
assert.Equal(t, len(rules), 2)
assert.True(t, strings.HasPrefix(canary.Status.SessionAffinityCookie, cookieKey))
stickyRule := rules[0]
cookieMatch := stickyRule.Matches[0].Headers[0]
assert.Equal(t, *cookieMatch.Type, v1.HeaderMatchRegularExpression)
assert.Equal(t, string(cookieMatch.Name), cookieHeader)
assert.Contains(t, cookieMatch.Value, cookieKey)
assert.Equal(t, len(stickyRule.BackendRefs), 2)
for _, backendRef := range stickyRule.BackendRefs {
if string(backendRef.BackendRef.Name) == pSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(0))
}
if string(backendRef.BackendRef.Name) == cSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(100))
}
}
weightedRule := rules[1]
var found bool
for _, backendRef := range weightedRule.BackendRefs {
if string(backendRef.Name) == cSvcName {
found = true
filter := backendRef.Filters[0]
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, found)
rules, err = router.getSessionAffinityRouteRules(canary, 0, weightedRouteRule)
require.NoError(t, err)
assert.Empty(t, canary.Status.SessionAffinityCookie)
assert.Contains(t, canary.Status.PreviousSessionAffinityCookie, cookieKey)
stickyRule = rules[0]
cookieMatch = stickyRule.Matches[0].Headers[0]
assert.Equal(t, *cookieMatch.Type, v1.HeaderMatchRegularExpression)
assert.Equal(t, string(cookieMatch.Name), cookieHeader)
assert.Contains(t, cookieMatch.Value, cookieKey)
assert.Equal(t, stickyRule.Filters[0].Type, v1.HTTPRouteFilterResponseHeaderModifier)
headerModifier := stickyRule.Filters[0].ResponseHeaderModifier
assert.NotNil(t, headerModifier)
assert.Equal(t, string(headerModifier.Add[0].Name), setCookieHeader)
assert.Equal(t, headerModifier.Add[0].Value, fmt.Sprintf("%s; %s=%d", canary.Status.PreviousSessionAffinityCookie, maxAgeAttr, -1))
})
t.Run("with primary cookie", func(t *testing.T) {
canary := newTestGatewayAPICanary()
mocks := newFixture(canary)
canaryCookieKey := "canary-flagger-cookie"
primaryCookieKey := "primary-flagger-cookie"
canary.Spec.Analysis.Interval = "15s"
canary.Spec.Analysis.SessionAffinity = &flaggerv1.SessionAffinity{
CookieName: canaryCookieKey,
PrimaryCookieName: primaryCookieKey,
MaxAge: 300,
}
router := &GatewayAPIRouter{
gatewayAPIClient: mocks.meshClient,
kubeClient: mocks.kubeClient,
logger: mocks.logger,
}
_, pSvcName, cSvcName := canary.GetServiceNames()
weightedRouteRule := &v1.HTTPRouteRule{
BackendRefs: []v1.HTTPBackendRef{
{
BackendRef: router.makeBackendRef(pSvcName, initialPrimaryWeight, canary.Spec.Service.Port),
},
{
BackendRef: router.makeBackendRef(cSvcName, initialCanaryWeight, canary.Spec.Service.Port),
},
},
},
}
rules, err := router.getSessionAffinityRouteRules(canary, 10, weightedRouteRule)
require.NoError(t, err)
assert.Equal(t, len(rules), 2)
assert.True(t, strings.HasPrefix(canary.Status.SessionAffinityCookie, cookieKey))
stickyRule := rules[0]
cookieMatch := stickyRule.Matches[0].Headers[0]
assert.Equal(t, *cookieMatch.Type, v1.HeaderMatchRegularExpression)
assert.Equal(t, string(cookieMatch.Name), cookieHeader)
assert.Contains(t, cookieMatch.Value, cookieKey)
assert.Equal(t, len(stickyRule.BackendRefs), 2)
for _, backendRef := range stickyRule.BackendRefs {
if string(backendRef.BackendRef.Name) == pSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(0))
}
if string(backendRef.BackendRef.Name) == cSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(100))
rules, err := router.getSessionAffinityRouteRules(canary, 10, weightedRouteRule)
require.NoError(t, err)
assert.Equal(t, len(rules), 3)
assert.True(t, strings.HasPrefix(canary.Status.SessionAffinityCookie, canaryCookieKey))
canaryStickyRule := rules[0]
cookieMatch := canaryStickyRule.Matches[0].Headers[0]
assert.Equal(t, *cookieMatch.Type, v1.HeaderMatchRegularExpression)
assert.Equal(t, string(cookieMatch.Name), cookieHeader)
assert.Contains(t, cookieMatch.Value, canaryCookieKey)
assert.Equal(t, len(canaryStickyRule.BackendRefs), 2)
for _, backendRef := range canaryStickyRule.BackendRefs {
if string(backendRef.BackendRef.Name) == pSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(0))
}
if string(backendRef.BackendRef.Name) == cSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(100))
}
}
}
weightedRule := rules[1]
var found bool
for _, backendRef := range weightedRule.BackendRefs {
if string(backendRef.Name) == cSvcName {
found = true
filter := backendRef.Filters[0]
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))
primaryStickyRule := rules[1]
cookieMatch = primaryStickyRule.Matches[0].Headers[0]
assert.Equal(t, *cookieMatch.Type, v1.HeaderMatchRegularExpression)
assert.Equal(t, string(cookieMatch.Name), cookieHeader)
assert.Contains(t, cookieMatch.Value, primaryCookieKey)
assert.Equal(t, len(primaryStickyRule.BackendRefs), 2)
for _, backendRef := range primaryStickyRule.BackendRefs {
if string(backendRef.BackendRef.Name) == pSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(100))
}
if string(backendRef.BackendRef.Name) == cSvcName {
assert.Equal(t, *backendRef.BackendRef.Weight, int32(0))
}
}
}
assert.True(t, found)
rules, err = router.getSessionAffinityRouteRules(canary, 0, weightedRouteRule)
assert.Empty(t, canary.Status.SessionAffinityCookie)
assert.Contains(t, canary.Status.PreviousSessionAffinityCookie, cookieKey)
weightedRule := rules[2]
var c int
for _, backendRef := range weightedRule.BackendRefs {
if string(backendRef.Name) == cSvcName {
c += 1
filter := backendRef.Filters[0]
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))
}
stickyRule = rules[0]
cookieMatch = stickyRule.Matches[0].Headers[0]
assert.Equal(t, *cookieMatch.Type, v1.HeaderMatchRegularExpression)
assert.Equal(t, string(cookieMatch.Name), cookieHeader)
assert.Contains(t, cookieMatch.Value, cookieKey)
if string(backendRef.Name) == pSvcName {
c += 1
filter := backendRef.Filters[0]
assert.Equal(t, filter.Type, v1.HTTPRouteFilterResponseHeaderModifier)
assert.NotNil(t, filter.ResponseHeaderModifier)
assert.Equal(t, string(filter.ResponseHeaderModifier.Add[0].Name), setCookieHeader)
assert.Contains(t, filter.ResponseHeaderModifier.Add[0].Value, canary.Spec.Analysis.SessionAffinity.PrimaryCookieName)
interval, err := time.ParseDuration(canary.Spec.Analysis.Interval)
require.NoError(t, err)
assert.Contains(t, filter.ResponseHeaderModifier.Add[0].Value, fmt.Sprintf("%s=%d", maxAgeAttr, int(interval.Seconds())))
}
}
assert.Equal(t, 2, c)
assert.Equal(t, stickyRule.Filters[0].Type, v1.HTTPRouteFilterResponseHeaderModifier)
headerModifier := stickyRule.Filters[0].ResponseHeaderModifier
assert.NotNil(t, headerModifier)
assert.Equal(t, string(headerModifier.Add[0].Name), setCookieHeader)
assert.Equal(t, headerModifier.Add[0].Value, fmt.Sprintf("%s; %s=%d", canary.Status.PreviousSessionAffinityCookie, maxAgeAttr, -1))
rules, err = router.getSessionAffinityRouteRules(canary, 0, weightedRouteRule)
require.NoError(t, err)
assert.Empty(t, canary.Status.SessionAffinityCookie)
assert.Contains(t, canary.Status.PreviousSessionAffinityCookie, canaryCookieKey)
canaryStickyRule = rules[0]
cookieMatch = canaryStickyRule.Matches[0].Headers[0]
assert.Equal(t, *cookieMatch.Type, v1.HeaderMatchRegularExpression)
assert.Equal(t, string(cookieMatch.Name), cookieHeader)
assert.Contains(t, cookieMatch.Value, canaryCookieKey)
assert.Equal(t, canaryStickyRule.Filters[0].Type, v1.HTTPRouteFilterResponseHeaderModifier)
headerModifier := canaryStickyRule.Filters[0].ResponseHeaderModifier
assert.NotNil(t, headerModifier)
assert.Equal(t, string(headerModifier.Add[0].Name), setCookieHeader)
assert.Equal(t, headerModifier.Add[0].Value, fmt.Sprintf("%s; %s=%d", canary.Status.PreviousSessionAffinityCookie, maxAgeAttr, -1))
})
}
func TestGatewayAPIRouter_makeFilters(t *testing.T) {
+5 -3
View File
@@ -44,7 +44,8 @@ spec:
maxWeight: 50
stepWeight: 10
sessionAffinity:
cookieName: flagger-cookie
cookieName: canary-flagger-cookie
primaryCookieName: primary-flagger-cookie
metrics:
- name: error-rate
templateRef:
@@ -63,7 +64,7 @@ spec:
webhooks:
- name: load-test
type: rollout
url: http://flagger-loadtester.test/
url: http://localhost:8080
timeout: 5s
metadata:
cmd: "hey -z 2m -q 10 -c 2 -host www.example.com http://gateway-istio.istio-ingress"
@@ -104,7 +105,8 @@ until ${ok}; do
done
echo '>>> Verifying session affinity'
if ! URL=http://localhost:8888 HOST=www.example.com VERSION=6.1.0 COOKIE_NAME=flagger-cookie \
if ! URL=http://localhost:8888 HOST=www.example.com CANARY_VERSION=6.1.0 \
CANARY_COOKIE_NAME=canary-flagger-cookie PRIMARY_VERSION=6.0.4 PRIMARY_COOKIE_NAME=primary-flagger-cookie \
go run ${REPO_ROOT}/test/gatewayapi/verify_session_affinity.go; then
echo "failed to verify session affinity"
exit $?
+33 -32
View File
@@ -2,7 +2,7 @@ package main
import (
"fmt"
"io/ioutil"
"io"
"log"
"net/http"
"os"
@@ -11,30 +11,39 @@ import (
"time"
)
var c = make(chan string, 1)
var mu sync.Mutex
var try = true
// channel for canary cookie
var cc = make(chan string, 1)
// channel for primary cookie
var pc = make(chan string, 1)
var timeout = time.Second * 10
func main() {
url := os.Getenv("URL")
host := os.Getenv("HOST")
version := os.Getenv("VERSION")
cookieName := os.Getenv("COOKIE_NAME")
canaryVersion := os.Getenv("CANARY_VERSION")
canaryCookieName := os.Getenv("CANARY_COOKIE_NAME")
primaryVersion := os.Getenv("PRIMARY_VERSION")
primaryCookieName := os.Getenv("PRIMARY_COOKIE_NAME")
// Generate traffic
for i := 0; i < 10; i++ {
go tryUntilCanaryIsHit(url, host, version, cookieName)
}
go tryUntilWorkloadIsHit(url, host, canaryVersion, canaryCookieName, cc)
go tryUntilWorkloadIsHit(url, host, primaryVersion, primaryCookieName, pc)
wg := &sync.WaitGroup{}
wg.Add(2)
go verifySessionAffinity(cc, wg, url, host, primaryVersion)
go verifySessionAffinity(pc, wg, url, host, canaryVersion)
wg.Wait()
log.Println("✔ successfully verified session affinity")
}
func verifySessionAffinity(cc chan string, wg *sync.WaitGroup, url, host, wrongVersion string) {
select {
// If we receive a cookie, then try to verify that we are always routed to the
// Canary deployment based on the cookie.
case cookie := <-c:
mu.Lock()
try = false
mu.Unlock()
case cookie := <-cc:
for i := 0; i < 5; i++ {
headers := map[string]string{
"Cookie": cookie,
@@ -43,15 +52,15 @@ func main() {
if err != nil {
log.Fatalf("failed to send request to verify cookie based routing: %v", err)
}
if !strings.Contains(body, version) {
log.Fatalf("received response from primary deployment instead of canary deployment")
if strings.Contains(body, wrongVersion) {
log.Fatalf("received response from the wrong deployment")
}
}
log.Println("✔ successfully verified session affinity")
wg.Done()
case <-time.After(timeout):
log.Fatal("timed out waiting for canary hit")
log.Fatal("timed out waiting for workload hit")
}
}
// sendRequest sends a request to the URL with the provided host and headers.
@@ -74,7 +83,7 @@ func sendRequest(url, host string, headers map[string]string) (string, []*http.C
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, err
}
@@ -82,17 +91,10 @@ func sendRequest(url, host string, headers map[string]string) (string, []*http.C
return string(body), resp.Cookies(), nil
}
// tryUntilCanaryIsHit is a recursive function that tries to send request and
// tryUntilWorkloadIsHit is a recursive function that tries to send request and
// either sends the cookie back to the main thread (if received) or re-sends
// the request.
func tryUntilCanaryIsHit(url, host, version, cookieName string) {
mu.Lock()
if !try {
mu.Unlock()
return
}
mu.Unlock()
func tryUntilWorkloadIsHit(url, host, version, cookieName string, c chan string) {
body, cookies, err := sendRequest(url, host, nil)
if err != nil {
log.Printf("warning: failed to send request: %s", err)
@@ -105,6 +107,5 @@ func tryUntilCanaryIsHit(url, host, version, cookieName string) {
}
}
tryUntilCanaryIsHit(url, host, version, cookieName)
return
tryUntilWorkloadIsHit(url, host, version, cookieName, c)
}