Adds support for setting primary cookie name in istio router

Signed-off-by: Renato Vassão <renato.vassao@mindbodyonline.com>
This commit is contained in:
Renato Vassão
2025-11-18 15:08:25 -03:00
parent 931dd7fa6b
commit 4821a687c1
2 changed files with 342 additions and 100 deletions

View File

@@ -49,7 +49,6 @@ type IstioRouter struct {
const cookieHeader = "Cookie"
const setCookieHeader = "Set-Cookie"
const stickyRouteName = "sticky-route"
const maxAgeAttr = "Max-Age"
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
@@ -182,8 +181,8 @@ func (ir *IstioRouter) reconcileVirtualService(canary *flaggerv1.Canary) error {
// create destinations with primary weight 100% and canary weight 0%
canaryRoute := []istiov1beta1.HTTPRouteDestination{
makeDestination(canary, primaryName, 100),
makeDestination(canary, canaryName, 0),
ir.makeDestination(canary, primaryName, 100),
ir.makeDestination(canary, canaryName, 0),
}
if canary.Spec.Service.Delegation {
@@ -255,7 +254,7 @@ func (ir *IstioRouter) reconcileVirtualService(canary *flaggerv1.Canary) error {
CorsPolicy: canary.Spec.Service.CorsPolicy,
Headers: canary.Spec.Service.Headers,
Route: []istiov1beta1.HTTPRouteDestination{
makeDestination(canary, primaryName, 100),
ir.makeDestination(canary, primaryName, 100),
},
},
}
@@ -304,15 +303,21 @@ func (ir *IstioRouter) reconcileVirtualService(canary *flaggerv1.Canary) error {
cmpopts.IgnoreFields(istiov1beta1.HTTPRoute{}, "Mirror", "MirrorPercentage"),
}
if canary.Spec.Analysis.SessionAffinity != nil {
// We ignore this route as this does not do weighted routing and is handled exclusively
// by SetRoutes().
ignoreSlice := cmpopts.IgnoreSliceElements(func(t istiov1beta1.HTTPRoute) bool {
if t.Name == stickyRouteName {
return true
ignoreCookieRouteFunc := func(name string) func(r istiov1beta1.HTTPRoute) bool {
return func(r istiov1beta1.HTTPRoute) bool {
// Ignore the rule that does sticky routing, i.e. matches against the `Cookie` header.
for _, match := range r.Match {
if strings.Contains(match.Headers[cookieHeader].Regex, name) {
return true
}
}
return false
}
return false
})
ignoreCmpOptions = append(ignoreCmpOptions, ignoreSlice)
}
ignoreCanaryRoute := cmpopts.IgnoreSliceElements(ignoreCookieRouteFunc(canary.Spec.Analysis.SessionAffinity.CookieName))
ignorePrimaryRoute := cmpopts.IgnoreSliceElements(ignoreCookieRouteFunc(canary.Spec.Analysis.SessionAffinity.PrimaryCookieName))
ignoreCmpOptions = append(ignoreCmpOptions, ignoreCanaryRoute, ignorePrimaryRoute)
ignoreCmpOptions = append(ignoreCmpOptions, cmpopts.IgnoreFields(istiov1beta1.HTTPRouteDestination{}, "Headers"))
}
if v, ok := virtualService.Annotations[kubectlAnnotation]; ok {
@@ -481,8 +486,8 @@ func (ir *IstioRouter) SetRoutes(
weightedRoute := istiov1beta1.TCPRoute{
Match: canaryToL4Match(canary),
Route: []istiov1beta1.HTTPRouteDestination{
makeDestination(canary, primaryName, primaryWeight),
makeDestination(canary, canaryName, canaryWeight),
ir.makeDestination(canary, primaryName, primaryWeight),
ir.makeDestination(canary, canaryName, canaryWeight),
},
}
vsCopy.Spec.Tcp = []istiov1beta1.TCPRoute{
@@ -497,7 +502,7 @@ func (ir *IstioRouter) SetRoutes(
}
// weighted routing (progressive canary)
weightedRoute := istiov1beta1.HTTPRoute{
weightedRoute := &istiov1beta1.HTTPRoute{
Match: canary.Spec.Service.Match,
Rewrite: canary.Spec.Service.GetIstioRewrite(),
Timeout: canary.Spec.Service.Timeout,
@@ -505,94 +510,20 @@ func (ir *IstioRouter) SetRoutes(
CorsPolicy: canary.Spec.Service.CorsPolicy,
Headers: canary.Spec.Service.Headers,
Route: []istiov1beta1.HTTPRouteDestination{
makeDestination(canary, primaryName, primaryWeight),
makeDestination(canary, canaryName, canaryWeight),
ir.makeDestination(canary, primaryName, primaryWeight),
ir.makeDestination(canary, canaryName, canaryWeight),
},
}
vsCopy.Spec.Http = []istiov1beta1.HTTPRoute{
weightedRoute,
*weightedRoute,
}
if canary.Spec.Analysis.SessionAffinity != nil {
// 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.
stickyRoute := weightedRoute
stickyRoute.Name = stickyRouteName
if canaryWeight != 0 {
if canary.Status.SessionAffinityCookie == "" {
canary.Status.SessionAffinityCookie = fmt.Sprintf("%s=%s", canary.Spec.Analysis.SessionAffinity.CookieName, randSeq())
}
for i, routeDest := range weightedRoute.Route {
if routeDest.Destination.Host == canaryName {
if routeDest.Headers == nil {
routeDest.Headers = &istiov1beta1.Headers{
Response: &istiov1beta1.HeaderOperations{},
}
}
routeDest.Headers.Response.Add = map[string]string{
setCookieHeader: canary.Spec.Analysis.SessionAffinity.BuildCookie(canary.Status.SessionAffinityCookie),
}
}
weightedRoute.Route[i] = routeDest
}
cookieKeyAndVal := strings.Split(canary.Status.SessionAffinityCookie, "=")
cookieMatch := istiov1beta1.HTTPMatchRequest{
Headers: map[string]istiov1alpha1.StringMatch{
cookieHeader: {
Regex: fmt.Sprintf(".*%s.*%s.*", cookieKeyAndVal[0], cookieKeyAndVal[1]),
},
},
}
canaryMatch := mergeMatchConditions([]istiov1beta1.HTTPMatchRequest{cookieMatch}, canary.Spec.Service.Match)
stickyRoute.Match = canaryMatch
stickyRoute.Route = []istiov1beta1.HTTPRouteDestination{
makeDestination(canary, primaryName, 0),
makeDestination(canary, canaryName, 100),
}
} else {
// If canary weight is 0 and SessionAffinityCookie is non-blank, then it belongs to a previous canary run.
if canary.Status.SessionAffinityCookie != "" {
canary.Status.PreviousSessionAffinityCookie = canary.Status.SessionAffinityCookie
}
previousCookie := canary.Status.PreviousSessionAffinityCookie
// Match against the previous session cookie and delete that cookie
if previousCookie != "" {
cookieKeyAndVal := strings.Split(previousCookie, "=")
cookieMatch := istiov1beta1.HTTPMatchRequest{
Headers: map[string]istiov1alpha1.StringMatch{
cookieHeader: {
Regex: fmt.Sprintf(".*%s.*%s.*", cookieKeyAndVal[0], cookieKeyAndVal[1]),
},
},
}
canaryMatch := mergeMatchConditions([]istiov1beta1.HTTPMatchRequest{cookieMatch}, canary.Spec.Service.Match)
stickyRoute.Match = canaryMatch
if stickyRoute.Headers == nil {
stickyRoute.Headers = &istiov1beta1.Headers{
Response: &istiov1beta1.HeaderOperations{
Add: map[string]string{},
},
}
} else if stickyRoute.Headers.Response == nil {
stickyRoute.Headers.Response = &istiov1beta1.HeaderOperations{
Add: map[string]string{},
}
} else if stickyRoute.Headers.Response.Add == nil {
stickyRoute.Headers.Response.Add = map[string]string{}
}
stickyRoute.Headers.Response.Add[setCookieHeader] = fmt.Sprintf("%s; %s=%d", previousCookie, maxAgeAttr, -1)
}
canary.Status.SessionAffinityCookie = ""
}
vsCopy.Spec.Http = []istiov1beta1.HTTPRoute{
stickyRoute, weightedRoute,
rules, err := ir.getSessionAffinityRouteRules(canary, canaryWeight, weightedRoute)
if err != nil {
return err
}
vsCopy.Spec.Http = rules
}
if mirrored {
@@ -618,8 +549,8 @@ func (ir *IstioRouter) SetRoutes(
CorsPolicy: canary.Spec.Service.CorsPolicy,
Headers: canary.Spec.Service.Headers,
Route: []istiov1beta1.HTTPRouteDestination{
makeDestination(canary, primaryName, primaryWeight),
makeDestination(canary, canaryName, canaryWeight),
ir.makeDestination(canary, primaryName, primaryWeight),
ir.makeDestination(canary, canaryName, canaryWeight),
},
},
{
@@ -630,7 +561,7 @@ func (ir *IstioRouter) SetRoutes(
CorsPolicy: canary.Spec.Service.CorsPolicy,
Headers: canary.Spec.Service.Headers,
Route: []istiov1beta1.HTTPRouteDestination{
makeDestination(canary, primaryName, primaryWeight),
ir.makeDestination(canary, primaryName, primaryWeight),
},
},
}
@@ -705,7 +636,7 @@ func mergeMatchConditions(canary, defaults []istiov1beta1.HTTPMatchRequest) []is
}
// makeDestination returns a an destination weight for the specified host
func makeDestination(canary *flaggerv1.Canary, host string, weight int) istiov1beta1.HTTPRouteDestination {
func (ir *IstioRouter) makeDestination(canary *flaggerv1.Canary, host string, weight int) istiov1beta1.HTTPRouteDestination {
dest := istiov1beta1.HTTPRouteDestination{
Destination: istiov1beta1.Destination{
Host: host,
@@ -740,3 +671,144 @@ func randSeq() string {
}
return string(b)
}
func getRouteByServiceName(rule *istiov1beta1.HTTPRoute, svcName string) *istiov1beta1.HTTPRouteDestination {
for i, routeDest := range rule.Route {
if routeDest.Destination.Host == svcName {
return &rule.Route[i]
}
}
return nil
}
// getSessionAffinityRouteRules returns the HTTPRoute objects required to perform
// session affinity based Canary releases.
func (ir *IstioRouter) getSessionAffinityRouteRules(canary *flaggerv1.Canary, canaryWeight int,
weightedRoute *istiov1beta1.HTTPRoute) ([]istiov1beta1.HTTPRoute, error) {
_, primaryName, canaryName := canary.GetServiceNames()
stickyCanaryRoute := *weightedRoute
stickyPrimaryRoute := *weightedRoute
// 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())
}
// if the status doesn't have the primary cookie, then generate a new primary cookie.
if canary.Status.PrimarySessionAffinityCookie == "" {
canary.Status.PrimarySessionAffinityCookie = fmt.Sprintf("%s=%s", canary.Spec.Analysis.SessionAffinity.PrimaryCookieName, randSeq())
}
// add response modifier to the canary backend route in the rule that does weighted routing
// to include the canary cookie.
canaryBackendRoute := getRouteByServiceName(weightedRoute, canaryName)
if canaryBackendRoute.Headers == nil {
canaryBackendRoute.Headers = &istiov1beta1.Headers{
Response: &istiov1beta1.HeaderOperations{},
}
}
canaryBackendRoute.Headers.Response.Add = map[string]string{
setCookieHeader: canary.Spec.Analysis.SessionAffinity.BuildCookie(canary.Status.SessionAffinityCookie, canary.Spec.Analysis.SessionAffinity.GetMaxAge()),
}
// add response modifier to the primary backend route 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 != "" {
primaryBackendRoute := getRouteByServiceName(weightedRoute, primaryName)
interval, err := time.ParseDuration(canary.Spec.Analysis.Interval)
if err != nil {
return nil, fmt.Errorf("failed to parse canary interval: %w", err)
}
if primaryBackendRoute.Headers == nil {
primaryBackendRoute.Headers = &istiov1beta1.Headers{
Response: &istiov1beta1.HeaderOperations{},
}
}
primaryBackendRoute.Headers.Response.Add = map[string]string{
setCookieHeader: canary.Spec.Analysis.SessionAffinity.BuildCookie(canary.Status.PrimarySessionAffinityCookie, int(interval.Seconds())),
}
}
// 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, "=")
cookieMatch := istiov1beta1.HTTPMatchRequest{
Headers: map[string]istiov1alpha1.StringMatch{
cookieHeader: {
Regex: fmt.Sprintf(".*%s.*%s.*", cookieKeyAndVal[0], cookieKeyAndVal[1]),
},
},
}
canaryMatch := mergeMatchConditions([]istiov1beta1.HTTPMatchRequest{cookieMatch}, canary.Spec.Service.Match)
stickyCanaryRoute.Match = canaryMatch
stickyCanaryRoute.Route = []istiov1beta1.HTTPRouteDestination{
ir.makeDestination(canary, primaryName, 0),
ir.makeDestination(canary, canaryName, 100),
}
// configure the 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(canary.Status.PrimarySessionAffinityCookie, "=")
primaryCookieMatch := istiov1beta1.HTTPMatchRequest{
Headers: map[string]istiov1alpha1.StringMatch{
cookieHeader: {
Regex: fmt.Sprintf(".*%s.*%s.*", cookieKeyAndVal[0], cookieKeyAndVal[1]),
},
},
}
primaryMatch := mergeMatchConditions([]istiov1beta1.HTTPMatchRequest{primaryCookieMatch}, canary.Spec.Service.Match)
stickyPrimaryRoute.Match = primaryMatch
stickyPrimaryRoute.Route = []istiov1beta1.HTTPRouteDestination{
ir.makeDestination(canary, primaryName, 100),
ir.makeDestination(canary, canaryName, 0),
}
return []istiov1beta1.HTTPRoute{stickyCanaryRoute, stickyPrimaryRoute, *weightedRoute}, nil
}
return []istiov1beta1.HTTPRoute{stickyCanaryRoute, *weightedRoute}, nil
} else {
// If canary weight is 0 and SessionAffinityCookie is non-blank, then it belongs to a previous canary run.
if canary.Status.SessionAffinityCookie != "" {
canary.Status.PreviousSessionAffinityCookie = canary.Status.SessionAffinityCookie
}
previousCookie := canary.Status.PreviousSessionAffinityCookie
// Match against the previous session cookie and delete that cookie
if previousCookie != "" {
cookieKeyAndVal := strings.Split(previousCookie, "=")
cookieMatch := istiov1beta1.HTTPMatchRequest{
Headers: map[string]istiov1alpha1.StringMatch{
cookieHeader: {
Regex: fmt.Sprintf(".*%s.*%s.*", cookieKeyAndVal[0], cookieKeyAndVal[1]),
},
},
}
canaryMatch := mergeMatchConditions([]istiov1beta1.HTTPMatchRequest{cookieMatch}, canary.Spec.Service.Match)
stickyCanaryRoute.Match = canaryMatch
if stickyCanaryRoute.Headers == nil {
stickyCanaryRoute.Headers = &istiov1beta1.Headers{
Response: &istiov1beta1.HeaderOperations{
Add: map[string]string{},
},
}
} else if stickyCanaryRoute.Headers.Response == nil {
stickyCanaryRoute.Headers.Response = &istiov1beta1.HeaderOperations{
Add: map[string]string{},
}
} else if stickyCanaryRoute.Headers.Response.Add == nil {
stickyCanaryRoute.Headers.Response.Add = map[string]string{}
}
stickyCanaryRoute.Headers.Response.Add[setCookieHeader] = fmt.Sprintf("%s; %s=%d", previousCookie, maxAgeAttr, -1)
}
canary.Status.SessionAffinityCookie = ""
return []istiov1beta1.HTTPRoute{stickyCanaryRoute, *weightedRoute}, nil
}
}

View File

@@ -22,6 +22,7 @@ import (
"fmt"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
@@ -404,6 +405,175 @@ func TestIstioRouter_SetRoutes(t *testing.T) {
})
}
func TestIstioRouter_getSessionAffinityRoutes(t *testing.T) {
mocks := newFixture(nil)
t.Run("without primary cookie", func(t *testing.T) {
canary := mocks.canary.DeepCopy()
cookieKey := "flagger-cookie"
canary.Spec.Analysis.SessionAffinity = &flaggerv1.SessionAffinity{
CookieName: cookieKey,
MaxAge: 300,
}
router := &IstioRouter{
logger: mocks.logger,
flaggerClient: mocks.flaggerClient,
istioClient: mocks.meshClient,
kubeClient: mocks.kubeClient,
}
_, pSvcName, cSvcName := canary.GetServiceNames()
weightedRoute := &istiov1beta1.HTTPRoute{
Route: []istiov1beta1.HTTPRouteDestination{
router.makeDestination(canary, pSvcName, 100),
router.makeDestination(canary, cSvcName, 0),
},
}
rules, err := router.getSessionAffinityRouteRules(canary, 10, weightedRoute)
require.NoError(t, err)
assert.Equal(t, len(rules), 2)
assert.True(t, strings.HasPrefix(canary.Status.SessionAffinityCookie, cookieKey))
stickyRule := rules[0]
cookieMatch := stickyRule.Match[0].Headers[cookieHeader]
assert.NotNil(t, cookieMatch.Regex)
assert.Contains(t, cookieMatch.Regex, cookieKey)
assert.Equal(t, len(stickyRule.Route), 2)
for _, route := range stickyRule.Route {
if string(route.Destination.Host) == pSvcName {
assert.Equal(t, route.Weight, int(0))
}
if string(route.Destination.Host) == cSvcName {
assert.Equal(t, route.Weight, int(100))
}
}
weightedRule := rules[1]
var found bool
for _, route := range weightedRule.Route {
if string(route.Destination.Host) == cSvcName {
found = true
assert.NotNil(t, route.Headers.Response.Add)
assert.Equal(t, route.Headers.Response.Add[setCookieHeader], fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, 300))
}
}
assert.True(t, found)
rules, err = router.getSessionAffinityRouteRules(canary, 0, weightedRoute)
require.NoError(t, err)
assert.Empty(t, canary.Status.SessionAffinityCookie)
assert.Contains(t, canary.Status.PreviousSessionAffinityCookie, cookieKey)
stickyRule = rules[0]
cookieMatch = stickyRule.Match[0].Headers[cookieHeader]
assert.NotNil(t, cookieMatch.Regex)
assert.Contains(t, cookieMatch.Regex, cookieKey)
assert.NotNil(t, stickyRule.Headers.Response.Add)
assert.Equal(t, stickyRule.Headers.Response.Add[setCookieHeader], fmt.Sprintf("%s; %s=%d", canary.Status.PreviousSessionAffinityCookie, maxAgeAttr, -1))
})
t.Run("with primary cookie", func(t *testing.T) {
canary := mocks.canary.DeepCopy()
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 := &IstioRouter{
logger: mocks.logger,
flaggerClient: mocks.flaggerClient,
istioClient: mocks.meshClient,
kubeClient: mocks.kubeClient,
}
_, pSvcName, cSvcName := canary.GetServiceNames()
weightedRoute := &istiov1beta1.HTTPRoute{
Route: []istiov1beta1.HTTPRouteDestination{
router.makeDestination(canary, pSvcName, 100),
router.makeDestination(canary, cSvcName, 0),
},
}
rules, err := router.getSessionAffinityRouteRules(canary, 10, weightedRoute)
require.NoError(t, err)
assert.Equal(t, len(rules), 3)
assert.True(t, strings.HasPrefix(canary.Status.SessionAffinityCookie, canaryCookieKey))
canaryStickyRule := rules[0]
cookieMatch := canaryStickyRule.Match[0].Headers[cookieHeader]
assert.NotNil(t, cookieMatch.Regex)
assert.Contains(t, cookieMatch.Regex, canaryCookieKey)
assert.Equal(t, len(canaryStickyRule.Route), 2)
for _, route := range canaryStickyRule.Route {
if string(route.Destination.Host) == pSvcName {
assert.Equal(t, route.Weight, int(0))
}
if string(route.Destination.Host) == cSvcName {
assert.Equal(t, route.Weight, int(100))
}
}
primaryStickyRule := rules[1]
cookieMatch = primaryStickyRule.Match[0].Headers[cookieHeader]
assert.NotNil(t, cookieMatch.Regex)
assert.Contains(t, cookieMatch.Regex, primaryCookieKey)
assert.Equal(t, len(primaryStickyRule.Route), 2)
for _, route := range primaryStickyRule.Route {
if string(route.Destination.Host) == pSvcName {
assert.Equal(t, route.Weight, int(100))
}
if string(route.Destination.Host) == cSvcName {
assert.Equal(t, route.Weight, int(0))
}
}
weightedRule := rules[2]
var c int
for _, route := range weightedRule.Route {
if string(route.Destination.Host) == cSvcName {
c += 1
assert.NotNil(t, route.Headers.Response.Add)
assert.Equal(t, route.Headers.Response.Add[setCookieHeader], fmt.Sprintf("%s; %s=%d", canary.Status.SessionAffinityCookie, maxAgeAttr, 300))
}
if string(route.Destination.Host) == pSvcName {
c += 1
assert.NotNil(t, route.Headers.Response.Add)
assert.Contains(t, route.Headers.Response.Add[setCookieHeader], canary.Status.PrimarySessionAffinityCookie)
interval, err := time.ParseDuration(canary.Spec.Analysis.Interval)
require.NoError(t, err)
assert.Contains(t, route.Headers.Response.Add[setCookieHeader], fmt.Sprintf("%s=%d", maxAgeAttr, int(interval.Seconds())))
}
}
assert.Equal(t, 2, c)
rules, err = router.getSessionAffinityRouteRules(canary, 0, weightedRoute)
require.NoError(t, err)
assert.Empty(t, canary.Status.SessionAffinityCookie)
assert.Contains(t, canary.Status.PreviousSessionAffinityCookie, canaryCookieKey)
canaryStickyRule = rules[0]
cookieMatch = canaryStickyRule.Match[0].Headers[cookieHeader]
assert.NotNil(t, cookieMatch.Regex)
assert.Contains(t, cookieMatch.Regex, canaryCookieKey)
assert.NotNil(t, canaryStickyRule.Headers.Response.Add)
assert.Equal(t, canaryStickyRule.Headers.Response.Add[setCookieHeader], fmt.Sprintf("%s; %s=%d", canary.Status.PreviousSessionAffinityCookie, maxAgeAttr, -1))
})
}
func TestIstioRouter_GetRoutes(t *testing.T) {
mocks := newFixture(nil)
router := &IstioRouter{