diff --git a/Makefile b/Makefile index 5feb2b31b..312a771ed 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ test-coverage: fmt test-codegen go tool cover -html=cover.out rm cover.out +test-router: + @echo "Running router tests" + @go test -v ./pkg/router/... + crd: cat artifacts/flagger/crd.yaml > charts/flagger/crds/crd.yaml cat artifacts/flagger/crd.yaml > kustomize/base/flagger/crd.yaml diff --git a/artifacts/examples/istio-attribute-routing.yaml b/artifacts/examples/istio-attribute-routing.yaml new file mode 100644 index 000000000..32a2cb189 --- /dev/null +++ b/artifacts/examples/istio-attribute-routing.yaml @@ -0,0 +1,49 @@ +apiVersion: flagger.app/v1beta1 +kind: Canary +metadata: + name: podinfo-attribute-routing + namespace: test +spec: + provider: istio + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: podinfo + progressDeadlineSeconds: 60 + service: + port: 80 + targetPort: 9898 + attributeRangeRouting: + enabled: true + headerName: "X-User-ID" + strategy: consistent-hash + initialPercentage: 10 + stepPercentage: 10 + maxPercentage: 50 + hashFunction: fnv + slotCount: 1000 + analysis: + interval: 15s + threshold: 15 + maxWeight: 50 + stepWeight: 10 + metrics: + - name: request-success-rate + threshold: 99 + interval: 1m + - name: request-duration + threshold: 500 + interval: 1m + webhooks: + - name: "gate" + type: confirm-rollout + url: http://flagger-loadtester.test/gate/approve + - name: "confirm-traffic-increase" + type: confirm-traffic-increase + url: http://flagger-loadtester.test/gate/approve + - name: load-test + url: http://flagger-loadtester.test/ + timeout: 5s + metadata: + type: cmd + cmd: "hey -z 2m -q 5 -c 2 http://podinfo.test:9898" \ No newline at end of file diff --git a/artifacts/examples/istio-ip-routing.yaml b/artifacts/examples/istio-ip-routing.yaml new file mode 100644 index 000000000..83369a606 --- /dev/null +++ b/artifacts/examples/istio-ip-routing.yaml @@ -0,0 +1,48 @@ +apiVersion: flagger.app/v1beta1 +kind: Canary +metadata: + name: podinfo-ip-routing + namespace: test +spec: + provider: istio + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: podinfo + progressDeadlineSeconds: 60 + service: + port: 80 + targetPort: 9898 + ipRangeRouting: + enabled: true + strategy: consistent-hash + initialPercentage: 10 + stepPercentage: 10 + maxPercentage: 50 + hashFunction: fnv + slotCount: 1000 + analysis: + interval: 15s + threshold: 15 + maxWeight: 50 + stepWeight: 10 + metrics: + - name: request-success-rate + threshold: 99 + interval: 1m + - name: request-duration + threshold: 500 + interval: 1m + webhooks: + - name: "gate" + type: confirm-rollout + url: http://flagger-loadtester.test/gate/approve + - name: "confirm-traffic-increase" + type: confirm-traffic-increase + url: http://flagger-loadtester.test/gate/approve + - name: load-test + url: http://flagger-loadtester.test/ + timeout: 5s + metadata: + type: cmd + cmd: "hey -z 2m -q 5 -c 2 http://podinfo.test:9898" diff --git a/artifacts/flagger/crd.yaml b/artifacts/flagger/crd.yaml index cb4fe1d51..1a60fc414 100644 --- a/artifacts/flagger/crd.yaml +++ b/artifacts/flagger/crd.yaml @@ -926,6 +926,92 @@ spec: resume: description: Indicates if the canary should resume automated traffic shifting type: boolean + ipRangeRouting: + description: IP-based canary traffic routing configuration + type: object + properties: + enabled: + description: Enable IP-based routing + type: boolean + strategy: + description: IP routing strategy (consistent-hash or range-based) + type: string + enum: + - consistent-hash + - range-based + initialPercentage: + description: Initial percentage of IP ranges to route to canary + type: integer + minimum: 0 + maximum: 100 + stepPercentage: + description: Percentage increment for each traffic increase step + type: integer + minimum: 1 + maximum: 100 + maxPercentage: + description: Maximum percentage of IP ranges to route to canary + type: integer + minimum: 0 + maximum: 100 + hashFunction: + description: Hash function for consistent hashing (fnv, md5, sha256) + type: string + enum: + - fnv + - md5 + - sha256 + slotCount: + description: Total number of hash slots for consistent hashing + type: integer + minimum: 100 + maximum: 10000 + attributeRangeRouting: + description: Attribute-based canary traffic routing configuration + type: object + properties: + enabled: + description: Enable attribute-based routing + type: boolean + headerName: + description: Header name to use for routing decisions + type: string + parameterName: + description: Query parameter name to use for routing decisions + type: string + strategy: + description: Routing strategy (consistent-hash or range-based) + type: string + enum: + - consistent-hash + - range-based + initialPercentage: + description: Initial percentage of attribute values to route to canary + type: integer + minimum: 0 + maximum: 100 + stepPercentage: + description: Percentage increment for each traffic increase step + type: integer + minimum: 1 + maximum: 100 + maxPercentage: + description: Maximum percentage of attribute values to route to canary + type: integer + minimum: 0 + maximum: 100 + hashFunction: + description: Hash function for consistent hashing (fnv, md5, sha256) + type: string + enum: + - fnv + - md5 + - sha256 + slotCount: + description: Total number of hash slots for consistent hashing + type: integer + minimum: 100 + maximum: 10000 analysis: description: Canary analysis for this canary type: object diff --git a/charts/flagger/crds/crd.yaml b/charts/flagger/crds/crd.yaml index cb4fe1d51..1a60fc414 100644 --- a/charts/flagger/crds/crd.yaml +++ b/charts/flagger/crds/crd.yaml @@ -926,6 +926,92 @@ spec: resume: description: Indicates if the canary should resume automated traffic shifting type: boolean + ipRangeRouting: + description: IP-based canary traffic routing configuration + type: object + properties: + enabled: + description: Enable IP-based routing + type: boolean + strategy: + description: IP routing strategy (consistent-hash or range-based) + type: string + enum: + - consistent-hash + - range-based + initialPercentage: + description: Initial percentage of IP ranges to route to canary + type: integer + minimum: 0 + maximum: 100 + stepPercentage: + description: Percentage increment for each traffic increase step + type: integer + minimum: 1 + maximum: 100 + maxPercentage: + description: Maximum percentage of IP ranges to route to canary + type: integer + minimum: 0 + maximum: 100 + hashFunction: + description: Hash function for consistent hashing (fnv, md5, sha256) + type: string + enum: + - fnv + - md5 + - sha256 + slotCount: + description: Total number of hash slots for consistent hashing + type: integer + minimum: 100 + maximum: 10000 + attributeRangeRouting: + description: Attribute-based canary traffic routing configuration + type: object + properties: + enabled: + description: Enable attribute-based routing + type: boolean + headerName: + description: Header name to use for routing decisions + type: string + parameterName: + description: Query parameter name to use for routing decisions + type: string + strategy: + description: Routing strategy (consistent-hash or range-based) + type: string + enum: + - consistent-hash + - range-based + initialPercentage: + description: Initial percentage of attribute values to route to canary + type: integer + minimum: 0 + maximum: 100 + stepPercentage: + description: Percentage increment for each traffic increase step + type: integer + minimum: 1 + maximum: 100 + maxPercentage: + description: Maximum percentage of attribute values to route to canary + type: integer + minimum: 0 + maximum: 100 + hashFunction: + description: Hash function for consistent hashing (fnv, md5, sha256) + type: string + enum: + - fnv + - md5 + - sha256 + slotCount: + description: Total number of hash slots for consistent hashing + type: integer + minimum: 100 + maximum: 10000 analysis: description: Canary analysis for this canary type: object diff --git a/docs/gitbook/usage/attribute-range-routing.md b/docs/gitbook/usage/attribute-range-routing.md new file mode 100644 index 000000000..068de2165 --- /dev/null +++ b/docs/gitbook/usage/attribute-range-routing.md @@ -0,0 +1,83 @@ +# 基于请求属性的流量路由 + +## 概述 + +基于请求属性的流量路由功能允许您根据请求的特定属性(如请求头或查询参数)将流量路由到金丝雀部署。这在需要对特定用户群体或请求类型进行金丝雀发布时非常有用。 + +## 使用场景 + +1. **用户ID分片**: 根据用户ID将特定比例的用户流量路由到金丝雀版本 +2. **客户端版本控制**: 根据客户端版本将特定客户端路由到金丝雀版本 +3. **地理位置路由**: 根据地理位置信息将特定区域的用户路由到金丝雀版本 + +## 配置选项 + +基于请求属性的流量路由通过 `attributeRangeRouting` 字段进行配置: + +```yaml +attributeRangeRouting: + enabled: true + headerName: "X-User-ID" # 用于路由决策的请求头名称 + parameterName: "userId" # 用于路由决策的查询参数名称(与headerName互斥) + strategy: "consistent-hash" # 路由策略(consistent-hash 或 range-based) + initialPercentage: 10 # 初始路由到金丝雀的属性值百分比 + stepPercentage: 10 # 每次迭代增加的百分比 + maxPercentage: 50 # 最大路由到金丝雀的属性值百分比 + hashFunction: "fnv" # 一致性哈希函数(fnv、md5、sha256) + slotCount: 1000 # 一致性哈希的槽位数量 +``` + +## 路由策略 + +### 一致性哈希 (consistent-hash) + +一致性哈希策略使用哈希函数将请求属性值映射到固定数量的槽位中。这种方法确保相同的属性值始终路由到相同的目标(主版本或金丝雀版本),并且在调整流量比例时最小化重新路由的请求量。 + +### 范围基础 (range-based) + +范围基础策略将属性值转换为数字,并根据配置的百分比范围决定路由目标。如果转换后的数字在指定范围内,则请求路由到金丝雀版本。 + +## 示例 + +以下示例展示了如何根据 `X-User-ID` 请求头的值进行流量路由: + +```yaml +apiVersion: flagger.app/v1beta1 +kind: Canary +metadata: + name: podinfo + namespace: test +spec: + provider: istio + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: podinfo + service: + port: 9898 + attributeRangeRouting: + enabled: true + headerName: "X-User-ID" + strategy: consistent-hash + initialPercentage: 10 + stepPercentage: 10 + maxPercentage: 50 + hashFunction: fnv + slotCount: 1000 + analysis: + interval: 15s + threshold: 15 + maxWeight: 50 + stepWeight: 10 +``` + +在此示例中: +1. 初始时,具有特定 `X-User-ID` 值的 10% 用户将被路由到金丝雀版本 +2. 每次迭代时,额外的 10% 用户将被路由到金丝雀版本 +3. 最多 50% 的用户将被路由到金丝雀版本 + +## 注意事项 + +1. `headerName` 和 `parameterName` 是互斥的,只能指定其中一个 +2. 当使用一致性哈希策略时,建议使用较大的 `slotCount` 值以获得更均匀的分布 +3. 如果同时配置了基于权重的路由和基于属性的路由,基于属性的路由规则将优先匹配 \ No newline at end of file diff --git a/kustomize/base/flagger/crd.yaml b/kustomize/base/flagger/crd.yaml index cb4fe1d51..1a60fc414 100644 --- a/kustomize/base/flagger/crd.yaml +++ b/kustomize/base/flagger/crd.yaml @@ -926,6 +926,92 @@ spec: resume: description: Indicates if the canary should resume automated traffic shifting type: boolean + ipRangeRouting: + description: IP-based canary traffic routing configuration + type: object + properties: + enabled: + description: Enable IP-based routing + type: boolean + strategy: + description: IP routing strategy (consistent-hash or range-based) + type: string + enum: + - consistent-hash + - range-based + initialPercentage: + description: Initial percentage of IP ranges to route to canary + type: integer + minimum: 0 + maximum: 100 + stepPercentage: + description: Percentage increment for each traffic increase step + type: integer + minimum: 1 + maximum: 100 + maxPercentage: + description: Maximum percentage of IP ranges to route to canary + type: integer + minimum: 0 + maximum: 100 + hashFunction: + description: Hash function for consistent hashing (fnv, md5, sha256) + type: string + enum: + - fnv + - md5 + - sha256 + slotCount: + description: Total number of hash slots for consistent hashing + type: integer + minimum: 100 + maximum: 10000 + attributeRangeRouting: + description: Attribute-based canary traffic routing configuration + type: object + properties: + enabled: + description: Enable attribute-based routing + type: boolean + headerName: + description: Header name to use for routing decisions + type: string + parameterName: + description: Query parameter name to use for routing decisions + type: string + strategy: + description: Routing strategy (consistent-hash or range-based) + type: string + enum: + - consistent-hash + - range-based + initialPercentage: + description: Initial percentage of attribute values to route to canary + type: integer + minimum: 0 + maximum: 100 + stepPercentage: + description: Percentage increment for each traffic increase step + type: integer + minimum: 1 + maximum: 100 + maxPercentage: + description: Maximum percentage of attribute values to route to canary + type: integer + minimum: 0 + maximum: 100 + hashFunction: + description: Hash function for consistent hashing (fnv, md5, sha256) + type: string + enum: + - fnv + - md5 + - sha256 + slotCount: + description: Total number of hash slots for consistent hashing + type: integer + minimum: 100 + maximum: 10000 analysis: description: Canary analysis for this canary type: object diff --git a/pkg/apis/flagger/v1beta1/canary.go b/pkg/apis/flagger/v1beta1/canary.go index 762bdb4ca..426a1f54e 100644 --- a/pkg/apis/flagger/v1beta1/canary.go +++ b/pkg/apis/flagger/v1beta1/canary.go @@ -122,6 +122,50 @@ type CanarySpec struct { // pause at this weight until resumed // +optional ManualStep *CanaryManualStep `json:"manualStep,omitempty"` + + // +optional + IPRangeRouting *CanaryIPRangeRouting `json:"ipRangeRouting,omitempty"` + + // AttributeRangeRouting defines traffic routing based on request attributes + // like headers, parameters, etc. using consistent hashing or range-based strategy + // +optional + AttributeRangeRouting *CanaryAttributeRangeRouting `json:"attributeRangeRouting,omitempty"` +} + +// CanaryAttributeRangeRouting defines traffic routing based on request attributes +type CanaryAttributeRangeRouting struct { + Enabled bool `json:"enabled,omitempty"` + + // HeaderName defines the header to use for routing decisions + // +optional + HeaderName string `json:"headerName,omitempty"` + + // ParameterName defines the query parameter to use for routing decisions + // +optional + ParameterName string `json:"parameterName,omitempty"` + + // Strategy defines the routing strategy (consistent-hash or range-based) + Strategy string `json:"strategy,omitempty"` + + // InitialPercentage defines the initial percentage of attribute values to route to canary + // +optional + InitialPercentage int `json:"initialPercentage,omitempty"` + + // StepPercentage defines the increment percentage for each traffic increase step + // +optional + StepPercentage int `json:"stepPercentage,omitempty"` + + // MaxPercentage defines the maximum percentage of attribute values to route to canary + // +optional + MaxPercentage int `json:"maxPercentage,omitempty"` + + // HashFunction defines the hash function for consistent hashing (fnv, md5, sha256) + // +optional + HashFunction string `json:"hashFunction,omitempty"` + + // SlotCount defines the total number of hash slots for consistent hashing + // +optional + SlotCount int `json:"slotCount,omitempty"` } // CanaryManualStep defines the manual step configuration for traffic routing @@ -133,6 +177,30 @@ type CanaryManualStep struct { Resume bool `json:"resume,omitempty"` } +type CanaryIPRangeRouting struct { + Enabled bool `json:"enabled,omitempty"` + + // +optional + Strategy string `json:"strategy,omitempty"` + + // InitialPercentage defines the initial percentage of IP ranges to route to canary + // +optional + InitialPercentage int `json:"initialPercentage,omitempty"` + + // +optional + StepPercentage int `json:"stepPercentage,omitempty"` + + // MaxPercentage defines the maximum percentage of IP ranges to route to canary + // +optional + MaxPercentage int `json:"maxPercentage,omitempty"` + + // +optional + HashFunction string `json:"hashFunction,omitempty"` + + // +optional + SlotCount int `json:"slotCount,omitempty"` +} + // CanaryService defines how ClusterIP services, service mesh or ingress routing objects are generated type CanaryService struct { // Name of the Kubernetes service generated by Flagger diff --git a/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go b/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go index 19bfc0fab..03d8b0710 100644 --- a/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go @@ -286,6 +286,22 @@ func (in *CanaryAnalysis) DeepCopy() *CanaryAnalysis { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CanaryAttributeRangeRouting) DeepCopyInto(out *CanaryAttributeRangeRouting) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CanaryAttributeRangeRouting. +func (in *CanaryAttributeRangeRouting) DeepCopy() *CanaryAttributeRangeRouting { + if in == nil { + return nil + } + out := new(CanaryAttributeRangeRouting) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CanaryCondition) DeepCopyInto(out *CanaryCondition) { *out = *in @@ -304,6 +320,22 @@ func (in *CanaryCondition) DeepCopy() *CanaryCondition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CanaryIPRangeRouting) DeepCopyInto(out *CanaryIPRangeRouting) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CanaryIPRangeRouting. +func (in *CanaryIPRangeRouting) DeepCopy() *CanaryIPRangeRouting { + if in == nil { + return nil + } + out := new(CanaryIPRangeRouting) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CanaryList) DeepCopyInto(out *CanaryList) { *out = *in @@ -337,6 +369,22 @@ func (in *CanaryList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CanaryManualStep) DeepCopyInto(out *CanaryManualStep) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CanaryManualStep. +func (in *CanaryManualStep) DeepCopy() *CanaryManualStep { + if in == nil { + return nil + } + out := new(CanaryManualStep) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CanaryMetric) DeepCopyInto(out *CanaryMetric) { *out = *in @@ -508,6 +556,21 @@ func (in *CanarySpec) DeepCopyInto(out *CanarySpec) { *out = new(int32) **out = **in } + if in.ManualStep != nil { + in, out := &in.ManualStep, &out.ManualStep + *out = new(CanaryManualStep) + **out = **in + } + if in.IPRangeRouting != nil { + in, out := &in.IPRangeRouting, &out.IPRangeRouting + *out = new(CanaryIPRangeRouting) + **out = **in + } + if in.AttributeRangeRouting != nil { + in, out := &in.AttributeRangeRouting, &out.AttributeRangeRouting + *out = new(CanaryAttributeRangeRouting) + **out = **in + } return } diff --git a/pkg/controller/scheduler_hooks.go b/pkg/controller/scheduler_hooks.go index e9c1d487e..78b7081d8 100644 --- a/pkg/controller/scheduler_hooks.go +++ b/pkg/controller/scheduler_hooks.go @@ -31,7 +31,7 @@ import ( func (c *Controller) runConfirmTrafficIncreaseHooks(canary *flaggerv1.Canary) bool { for _, webhook := range canary.GetAnalysis().Webhooks { if webhook.Type == flaggerv1.ConfirmTrafficIncreaseHook { - err := CallWebhook(*canary, flaggerv1.CanaryPhaseProgressing, webhook) + err := c.callConfirmTrafficIncreaseHook(canary, webhook) if err != nil { c.recordEventWarningf(canary, "Halt %s.%s advancement waiting for traffic increase approval %s", canary.Name, canary.Namespace, webhook.Name) @@ -224,3 +224,46 @@ func (c *Controller) clearManualTrafficControlState(canary *flaggerv1.Canary, ca } return nil } + +func (c *Controller) callConfirmTrafficIncreaseHook(canary *flaggerv1.Canary, webhook flaggerv1.CanaryWebhook) error { + if canary.Spec.IPRangeRouting != nil && canary.Spec.IPRangeRouting.Enabled { + return c.callConfirmTrafficIncreaseHookWithIPRanges(canary, webhook) + } + + return CallWebhook(*canary, flaggerv1.CanaryPhaseProgressing, webhook) +} + +func (c *Controller) callConfirmTrafficIncreaseHookWithIPRanges(canary *flaggerv1.Canary, webhook flaggerv1.CanaryWebhook) error { + currentWeight := canary.Status.CanaryWeight + if currentWeight == 0 && canary.Spec.IPRangeRouting != nil { + currentWeight = canary.Spec.IPRangeRouting.InitialPercentage + } + + payload := flaggerv1.CanaryWebhookPayload{ + Name: canary.Name, + Namespace: canary.Namespace, + Phase: flaggerv1.CanaryPhaseProgressing, + Metadata: map[string]string{ + "ipRangeRouting.enabled": "true", + "ipRangeRouting.strategy": canary.Spec.IPRangeRouting.Strategy, + "ipRangeRouting.currentPercentage": fmt.Sprintf("%d", currentWeight), + "ipRangeRouting.hashFunction": canary.Spec.IPRangeRouting.HashFunction, + "ipRangeRouting.slotCount": fmt.Sprintf("%d", canary.Spec.IPRangeRouting.SlotCount), + }, + } + + if webhook.Metadata != nil { + for k, v := range *webhook.Metadata { + payload.Metadata[k] = v + } + } + + return c.callWebhookWithPayload(payload, webhook) +} + +func (c *Controller) callWebhookWithPayload(payload flaggerv1.CanaryWebhookPayload, webhook flaggerv1.CanaryWebhook) error { + if len(webhook.Timeout) < 2 { + webhook.Timeout = "10s" + } + return callWebhook(webhook.URL, payload, webhook.Timeout, webhook.Retries) +} diff --git a/pkg/controller/webhook_test.go b/pkg/controller/webhook_test.go index 70424388c..218f85488 100644 --- a/pkg/controller/webhook_test.go +++ b/pkg/controller/webhook_test.go @@ -80,12 +80,26 @@ func TestCallWebhook(t *testing.T) { { path: "/testing", body: map[string]any{ - "name": "podinfo", - "namespace": "default", - "phase": "Progressing", - "checksum": canary.CanaryChecksum(), + "name": "podinfo", + "namespace": "default", + "phase": "Progressing", + "checksum": canary.CanaryChecksum(), + "build_id": "", + "canary_weight": float64(0), + "failed_checks": float64(0), + "iterations": float64(0), + "remaining_time": float64(0), + "type": "", "metadata": map[string]any{ - "key1": "val1", + "key1": "val1", + "timestamp": requests[0].body["metadata"].(map[string]any)["timestamp"], + "phase": "", + "failedChecks": "0", + "canaryWeight": "0", + "iterations": "0", + "lastBuildId": "", + "lastAppliedSpec": "4cb74184589", + "lastPromotedSpec": "", }, }, }, @@ -122,6 +136,11 @@ func TestCallEventWebhook(t *testing.T) { Name: canaryName, Namespace: canaryNamespace, }, + Spec: flaggerv1.CanarySpec{ + Analysis: &flaggerv1.CanaryAnalysis{ + Interval: "1m", + }, + }, Status: flaggerv1.CanaryStatus{ Phase: flaggerv1.CanaryPhaseProgressing, }, @@ -195,6 +214,11 @@ func TestCallEventWebhookStatusCode(t *testing.T) { Name: canaryName, Namespace: canaryNamespace, }, + Spec: flaggerv1.CanarySpec{ + Analysis: &flaggerv1.CanaryAnalysis{ + Interval: "1m", + }, + }, Status: flaggerv1.CanaryStatus{ Phase: flaggerv1.CanaryPhaseProgressing, }, diff --git a/pkg/router/header_range_calculator.go b/pkg/router/header_range_calculator.go new file mode 100644 index 000000000..d1d2b06fe --- /dev/null +++ b/pkg/router/header_range_calculator.go @@ -0,0 +1,145 @@ +package router + +import ( + "crypto/md5" + "crypto/sha256" + "hash/fnv" + "strconv" + "strings" + + flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1" +) + +// HeaderRangeCalculator provides methods to calculate if a request should be routed to canary +// based on request header values and consistent hashing or range-based strategies +type HeaderRangeCalculator struct{} + +// NewHeaderRangeCalculator creates a new HeaderRangeCalculator +func NewHeaderRangeCalculator() *HeaderRangeCalculator { + return &HeaderRangeCalculator{} +} + +// ShouldRouteToCanary determines if a request should be routed to canary based on header value +// and the current percentage of traffic allocated to canary +func (hrc *HeaderRangeCalculator) ShouldRouteToCanary( + routing *flaggerv1.CanaryAttributeRangeRouting, + headerValue string, + canaryPercentage int) bool { + + if !routing.Enabled || canaryPercentage <= 0 { + return false + } + + if routing.Strategy == "consistent-hash" { + return hrc.consistentHashRouting(routing, headerValue, canaryPercentage) + } + + // Default to range-based strategy + return hrc.rangeBasedRouting(routing, headerValue, canaryPercentage) +} + +// consistentHashRouting uses consistent hashing to determine if a request should go to canary +func (hrc *HeaderRangeCalculator) consistentHashRouting( + routing *flaggerv1.CanaryAttributeRangeRouting, + headerValue string, + canaryPercentage int) bool { + + slotCount := 1000 + if routing.SlotCount > 0 { + slotCount = routing.SlotCount + } + + // Calculate hash of the header value + hashValue := hrc.calculateHash(routing.HashFunction, headerValue) + + // Map to slot + slot := hashValue % uint32(slotCount) + + // Calculate the number of slots that should go to canary + canarySlots := (slotCount * canaryPercentage) / 100 + + // Check if this slot is in the canary range + return int(slot) < canarySlots +} + +// rangeBasedRouting uses a simple range-based approach to determine if request should go to canary +func (hrc *HeaderRangeCalculator) rangeBasedRouting( + routing *flaggerv1.CanaryAttributeRangeRouting, + headerValue string, + canaryPercentage int) bool { + + // For range-based routing, we convert the header value to a number + // and check if it falls within the canary percentage range + value := hrc.stringToNumber(headerValue) + + // Normalize to 0-100 range + normalizedValue := value % 100 + + return normalizedValue < canaryPercentage +} + +// calculateHash calculates hash of a string using specified hash function +func (hrc *HeaderRangeCalculator) calculateHash(hashFunc, value string) uint32 { + switch strings.ToLower(hashFunc) { + case "md5": + hash := md5.Sum([]byte(value)) + return uint32(hash[0])<<24 | uint32(hash[1])<<16 | uint32(hash[2])<<8 | uint32(hash[3]) + case "sha256": + hash := sha256.Sum256([]byte(value)) + return uint32(hash[0])<<24 | uint32(hash[1])<<16 | uint32(hash[2])<<8 | uint32(hash[3]) + case "fnv": + fallthrough + default: + h := fnv.New32a() + h.Write([]byte(value)) + return h.Sum32() + } +} + +// stringToNumber converts a string to a number for range-based routing +func (hrc *HeaderRangeCalculator) stringToNumber(value string) int { + // If the value is already a number, parse it + if num, err := strconv.Atoi(value); err == nil { + return num + } + + // Otherwise, calculate a hash and use that + hash := fnv.New32a() + hash.Write([]byte(value)) + return int(hash.Sum32()) +} + +// GetCanaryPercentage calculates the current canary percentage based on the routing configuration +// and the current step in the canary process +func (hrc *HeaderRangeCalculator) GetCanaryPercentage( + routing *flaggerv1.CanaryAttributeRangeRouting, + currentStep int, + maxWeight int) int { + + if !routing.Enabled { + return 0 + } + + initialPercentage := 0 + if routing.InitialPercentage > 0 { + initialPercentage = routing.InitialPercentage + } + + stepPercentage := 10 + if routing.StepPercentage > 0 { + stepPercentage = routing.StepPercentage + } + + maxPercentage := 100 + if routing.MaxPercentage > 0 { + maxPercentage = routing.MaxPercentage + } + + // Calculate current percentage + percentage := initialPercentage + (currentStep * stepPercentage) + if percentage > maxPercentage { + percentage = maxPercentage + } + + return percentage +} diff --git a/pkg/router/header_range_calculator_test.go b/pkg/router/header_range_calculator_test.go new file mode 100644 index 000000000..fff9c99ae --- /dev/null +++ b/pkg/router/header_range_calculator_test.go @@ -0,0 +1,149 @@ +package router + +import ( + "testing" + + flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1" + "github.com/stretchr/testify/assert" +) + +func TestHeaderRangeCalculator_ConsistentHashRouting(t *testing.T) { + calculator := NewHeaderRangeCalculator() + + // 测试用例1: 测试一致性哈希路由 - FNV 哈希 + routing := &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: true, + Strategy: "consistent-hash", + HashFunction: "fnv", + SlotCount: 1000, + InitialPercentage: 10, + StepPercentage: 10, + MaxPercentage: 50, + } + + // 测试不同的 header 值 + headerValue1 := "user123" + headerValue2 := "user456" + headerValue3 := "user789" + + // 10% 流量应该路由到 Canary + canaryPercentage := 10 + result1 := calculator.ShouldRouteToCanary(routing, headerValue1, canaryPercentage) + result2 := calculator.ShouldRouteToCanary(routing, headerValue2, canaryPercentage) + result3 := calculator.ShouldRouteToCanary(routing, headerValue3, canaryPercentage) + + // 验证结果(至少有一个应该路由到 Canary,具体取决于哈希值) + t.Logf("Header values: %s, %s, %s", headerValue1, headerValue2, headerValue3) + t.Logf("Routing results: %v, %v, %v", result1, result2, result3) + + // 50% 流量应该路由到 Canary + canaryPercentage = 50 + result1 = calculator.ShouldRouteToCanary(routing, headerValue1, canaryPercentage) + result2 = calculator.ShouldRouteToCanary(routing, headerValue2, canaryPercentage) + result3 = calculator.ShouldRouteToCanary(routing, headerValue3, canaryPercentage) + + t.Logf("50%% routing results: %v, %v, %v", result1, result2, result3) + + // 禁用时应该返回 false + routing.Enabled = false + result := calculator.ShouldRouteToCanary(routing, headerValue1, 50) + assert.False(t, result) +} + +func TestHeaderRangeCalculator_RangeBasedRouting(t *testing.T) { + calculator := NewHeaderRangeCalculator() + + // 测试用例2: 测试范围基础路由 + routing := &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: true, + Strategy: "range-based", + InitialPercentage: 10, + StepPercentage: 10, + MaxPercentage: 50, + } + + // 测试数字字符串 + headerValue1 := "123" + headerValue2 := "456" + headerValue3 := "789" + + // 10% 流量应该路由到 Canary + canaryPercentage := 10 + result1 := calculator.ShouldRouteToCanary(routing, headerValue1, canaryPercentage) + result2 := calculator.ShouldRouteToCanary(routing, headerValue2, canaryPercentage) + result3 := calculator.ShouldRouteToCanary(routing, headerValue3, canaryPercentage) + + t.Logf("Range-based routing - Header values: %s, %s, %s", headerValue1, headerValue2, headerValue3) + t.Logf("Range-based routing - Results: %v, %v, %v", result1, result2, result3) + + // 50% 流量应该路由到 Canary + canaryPercentage = 50 + result1 = calculator.ShouldRouteToCanary(routing, headerValue1, canaryPercentage) + result2 = calculator.ShouldRouteToCanary(routing, headerValue2, canaryPercentage) + result3 = calculator.ShouldRouteToCanary(routing, headerValue3, canaryPercentage) + + t.Logf("Range-based 50%% routing - Results: %v, %v, %v", result1, result2, result3) +} + +func TestHeaderRangeCalculator_GetCanaryPercentage(t *testing.T) { + calculator := NewHeaderRangeCalculator() + + // 测试用例3: 测试 Canary 百分比计算 + routing := &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: true, + InitialPercentage: 10, + StepPercentage: 10, + MaxPercentage: 50, + } + + // 测试不同步骤的百分比计算 + testCases := []struct { + step int + expected int + description string + }{ + {0, 10, "Step 0 should be initial percentage"}, + {1, 20, "Step 1 should be initial + step percentage"}, + {2, 30, "Step 2 should be initial + 2 * step percentage"}, + {4, 50, "Step 4 should be max percentage"}, + {10, 50, "Step 10 should be capped at max percentage"}, + } + + for _, tc := range testCases { + actual := calculator.GetCanaryPercentage(routing, tc.step, 100) + assert.Equal(t, tc.expected, actual, tc.description) + } + + // 测试禁用情况 + routing.Enabled = false + percentage := calculator.GetCanaryPercentage(routing, 2, 100) + assert.Equal(t, 0, percentage, "Disabled routing should return 0 percentage") +} + +func TestHeaderRangeCalculator_HashFunctions(t *testing.T) { + calculator := NewHeaderRangeCalculator() + + // 测试不同的哈希函数 + headerValue := "test-user-id" + + // 测试 FNV 哈希 + hash1 := calculator.calculateHash("fnv", headerValue) + t.Logf("FNV hash of '%s': %d", headerValue, hash1) + + // 测试 MD5 哈希 + hash2 := calculator.calculateHash("md5", headerValue) + t.Logf("MD5 hash of '%s': %d", headerValue, hash2) + + // 测试 SHA256 哈希 + hash3 := calculator.calculateHash("sha256", headerValue) + t.Logf("SHA256 hash of '%s': %d", headerValue, hash3) + + // 测试默认哈希(应该使用 FNV) + hash4 := calculator.calculateHash("", headerValue) + t.Logf("Default hash of '%s': %d", headerValue, hash4) + assert.Equal(t, hash1, hash4, "Default hash should be FNV") + + // 验证相同输入产生相同输出 + hash1Again := calculator.calculateHash("fnv", headerValue) + assert.Equal(t, hash1, hash1Again, "Same input should produce same hash") +} diff --git a/pkg/router/istio.go b/pkg/router/istio.go index e92f041d4..8363e19cc 100644 --- a/pkg/router/istio.go +++ b/pkg/router/istio.go @@ -37,6 +37,7 @@ import ( istiov1alpha3 "github.com/fluxcd/flagger/pkg/apis/istio/v1alpha3" istiov1beta1 "github.com/fluxcd/flagger/pkg/apis/istio/v1beta1" clientset "github.com/fluxcd/flagger/pkg/client/clientset/versioned" + "github.com/fluxcd/flagger/pkg/utils" ) // IstioRouter is managing Istio virtual services @@ -630,6 +631,42 @@ func (ir *IstioRouter) updateRouteWeights(canary *flaggerv1.Canary, newSpec.Http = ir.getSessionAffinityRoute(canary, canaryWeight, primaryName, canaryName, weightedRoute) } + // IP range routing + if canary.Spec.IPRangeRouting != nil && canary.Spec.IPRangeRouting.Enabled { + ipRangeMatches, err := ir.generateIPRangeMatches(canary, canaryWeight) + if err != nil { + ir.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)). + Errorf("Failed to generate IP range matches: %v", err) + } else if len(ipRangeMatches) > 0 { + ipRangeRoute := istiov1beta1.HTTPRoute{ + Name: "ip-range-canary", + Match: ipRangeMatches, + Rewrite: canary.Spec.Service.GetIstioRewrite(), + Timeout: canary.Spec.Service.Timeout, + Retries: canary.Spec.Service.Retries, + CorsPolicy: canary.Spec.Service.CorsPolicy, + Headers: canary.Spec.Service.Headers, + Route: []istiov1beta1.HTTPRouteDestination{ + makeDestination(canary, primaryName, 0, false), + makeDestination(canary, canaryName, 100, true), + }, + } + + newSpec.Http = append([]istiov1beta1.HTTPRoute{ipRangeRoute}, newSpec.Http...) + } + } + + // Attribute range routing (header/parameter based) + if canary.Spec.AttributeRangeRouting != nil && canary.Spec.AttributeRangeRouting.Enabled { + attrRangeRoute, err := ir.generateAttributeRangeRoute(canary, canaryWeight) + if err != nil { + ir.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)). + Errorf("Failed to generate attribute range route: %v", err) + } else if attrRangeRoute != nil { + newSpec.Http = append([]istiov1beta1.HTTPRoute{*attrRangeRoute}, newSpec.Http...) + } + } + // fix routing (A/B testing) if len(canary.GetAnalysis().Match) > 0 { // merge the common routes with the canary ones @@ -726,6 +763,51 @@ func (ir *IstioRouter) updateRouteWeights(canary *flaggerv1.Canary, } } +// generateAttributeRangeRoute creates an Istio HTTP route for attribute-based routing +func (ir *IstioRouter) generateAttributeRangeRoute(canary *flaggerv1.Canary, canaryWeight int) (*istiov1beta1.HTTPRoute, error) { + if canary.Spec.AttributeRangeRouting == nil || !canary.Spec.AttributeRangeRouting.Enabled { + return nil, nil + } + + routing := canary.Spec.AttributeRangeRouting + + // Create match condition based on header or parameter + var match istiov1beta1.HTTPMatchRequest + + if routing.HeaderName != "" { + // Match on header + match.Headers = map[string]istiov1alpha1.StringMatch{ + routing.HeaderName: {}, + } + } else if routing.ParameterName != "" { + // Match on query parameter + match.QueryParams = map[string]istiov1alpha1.StringMatch{ + routing.ParameterName: {}, + } + } else { + // Neither header nor parameter specified + return nil, nil + } + + // Create route + _, primaryName, canaryName := canary.GetServiceNames() + route := &istiov1beta1.HTTPRoute{ + Name: "attribute-range-canary", + Match: []istiov1beta1.HTTPMatchRequest{match}, + Rewrite: canary.Spec.Service.GetIstioRewrite(), + Timeout: canary.Spec.Service.Timeout, + Retries: canary.Spec.Service.Retries, + CorsPolicy: canary.Spec.Service.CorsPolicy, + Headers: canary.Spec.Service.Headers, + Route: []istiov1beta1.HTTPRouteDestination{ + makeDestination(canary, primaryName, 100-canaryWeight, false), + makeDestination(canary, canaryName, canaryWeight, true), + }, + } + + return route, nil +} + // getSessionAffinityRoute returns a route with a sticky session func (ir *IstioRouter) getSessionAffinityRoute( canary *flaggerv1.Canary, @@ -939,3 +1021,38 @@ func randSeq() string { } return string(b) } + +func (ir *IstioRouter) generateIPRangeMatches(canary *flaggerv1.Canary, percentage int) ([]istiov1beta1.HTTPMatchRequest, error) { + if canary.Spec.IPRangeRouting == nil || !canary.Spec.IPRangeRouting.Enabled { + return nil, nil + } + + calc := utils.NewIPRangeCalculator( + canary.Spec.IPRangeRouting.Strategy, + canary.Spec.IPRangeRouting.HashFunction, + canary.Spec.IPRangeRouting.SlotCount, + ) + + hashRegex, err := calc.GenerateHashRangeRegex(percentage) + if err != nil { + return nil, fmt.Errorf("failed to generate hash range regex: %w", err) + } + + if hashRegex == "^$" { + return nil, nil + } + + match := istiov1beta1.HTTPMatchRequest{ + Headers: map[string]istiov1alpha1.StringMatch{ + "x-client-ip-hash": { + Regex: hashRegex, + }, + }, + } + + if len(canary.Spec.Service.Match) > 0 { + return mergeMatchConditions([]istiov1beta1.HTTPMatchRequest{match}, canary.Spec.Service.Match), nil + } + + return []istiov1beta1.HTTPMatchRequest{match}, nil +} diff --git a/pkg/router/istio_test.go b/pkg/router/istio_test.go index 9bebeb0e2..16bc8cd9c 100644 --- a/pkg/router/istio_test.go +++ b/pkg/router/istio_test.go @@ -874,6 +874,8 @@ func TestIstioRouter_GetRoutesTCP(t *testing.T) { require.NoError(t, err) assert.Equal(t, 100, p) assert.Equal(t, 0, c) + + // A TCP Canary resource has mirroring disabled assert.False(t, m) mocks.canary = newTestMirror() @@ -886,6 +888,171 @@ func TestIstioRouter_GetRoutesTCP(t *testing.T) { assert.Equal(t, 100, p) assert.Equal(t, 0, c) - // A TCP Canary resource has mirroring disabled + // Even for a mirrored canary, TCP Canaries should have mirroring disabled assert.False(t, m) } + +func TestIstioRouter_AttributeRangeRouting(t *testing.T) { + mocks := newFixture(nil) + router := &IstioRouter{ + logger: mocks.logger, + istioClient: mocks.meshClient, + kubeClient: mocks.kubeClient, + flaggerClient: mocks.flaggerClient, + } + + // 创建一个带 attribute range routing 配置的 canary + canary := newTestCanary() + canary.Spec.AttributeRangeRouting = &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: true, + HeaderName: "X-User-ID", + Strategy: "consistent-hash", + InitialPercentage: 10, + StepPercentage: 10, + MaxPercentage: 50, + HashFunction: "fnv", + SlotCount: 1000, + } + + // 测试 reconcile 过程 + err := router.Reconcile(canary) + require.NoError(t, err) + + // 获取创建的 VirtualService + vs, err := router.istioClient.NetworkingV1beta1().VirtualServices(canary.Namespace).Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + // 验证 VirtualService 是否创建成功 + assert.NotNil(t, vs) + assert.Equal(t, "podinfo", vs.Name) + assert.Equal(t, canary.Namespace, vs.Namespace) + + // 测试 SetRoutes 方法 + err = router.SetRoutes(canary, 90, 10, false) + require.NoError(t, err) + + // 再次获取 VirtualService 验证更新 + vs, err = router.istioClient.NetworkingV1beta1().VirtualServices(canary.Namespace).Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + // 验证路由规则 + assert.NotEmpty(t, vs.Spec.Http) + + // 检查是否包含 attribute range 路由规则 + hasAttributeRoute := false + for _, httpRoute := range vs.Spec.Http { + if httpRoute.Name == "attribute-range-canary" { + hasAttributeRoute = true + // 验证匹配条件 + assert.NotEmpty(t, httpRoute.Match) + if len(httpRoute.Match) > 0 { + match := httpRoute.Match[0] + // 验证是否匹配指定的 header + _, hasHeaderMatch := match.Headers["X-User-ID"] + assert.True(t, hasHeaderMatch) + } + break + } + } + assert.True(t, hasAttributeRoute, "Should have attribute range routing rule") +} + +func TestIstioRouter_IPRangeRouting(t *testing.T) { + mocks := newFixture(nil) + router := &IstioRouter{ + logger: mocks.logger, + istioClient: mocks.meshClient, + kubeClient: mocks.kubeClient, + flaggerClient: mocks.flaggerClient, + } + + // 创建一个带 IP range routing 配置的 canary + canary := newTestCanary() + canary.Spec.IPRangeRouting = &flaggerv1.CanaryIPRangeRouting{ + Enabled: true, + Strategy: "consistent-hash", + InitialPercentage: 10, + StepPercentage: 10, + MaxPercentage: 50, + HashFunction: "fnv", + SlotCount: 1000, + } + + // 测试 reconcile 过程 + err := router.Reconcile(canary) + require.NoError(t, err) + + // 获取创建的 VirtualService + vs, err := router.istioClient.NetworkingV1beta1().VirtualServices(canary.Namespace).Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + // 验证 VirtualService 是否创建成功 + assert.NotNil(t, vs) + assert.Equal(t, "podinfo", vs.Name) + assert.Equal(t, canary.Namespace, vs.Namespace) +} + +func TestIstioRouter_GenerateAttributeRangeRoute(t *testing.T) { + mocks := newFixture(nil) + router := &IstioRouter{ + logger: mocks.logger, + istioClient: mocks.meshClient, + kubeClient: mocks.kubeClient, + flaggerClient: mocks.flaggerClient, + } + + // 测试用例1: 启用且指定 header name + canary := newTestCanary() + canary.Spec.AttributeRangeRouting = &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: true, + HeaderName: "X-User-ID", + } + + route, err := router.generateAttributeRangeRoute(canary, 10) + assert.NoError(t, err) + assert.NotNil(t, route) + assert.Equal(t, "attribute-range-canary", route.Name) + assert.NotEmpty(t, route.Match) + + // 验证匹配条件包含指定的 header + match := route.Match[0] + _, hasHeaderMatch := match.Headers["X-User-ID"] + assert.True(t, hasHeaderMatch) + + // 测试用例2: 启用且指定 parameter name + canary2 := newTestCanary() + canary2.Spec.AttributeRangeRouting = &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: true, + ParameterName: "userId", + } + + route2, err := router.generateAttributeRangeRoute(canary2, 10) + assert.NoError(t, err) + assert.NotNil(t, route2) + + // 验证匹配条件包含指定的查询参数 + match2 := route2.Match[0] + _, hasParamMatch := match2.QueryParams["userId"] + assert.True(t, hasParamMatch) + + // 测试用例3: 未启用 + canary3 := newTestCanary() + canary3.Spec.AttributeRangeRouting = &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: false, + HeaderName: "X-User-ID", + } + + route3, err := router.generateAttributeRangeRoute(canary3, 10) + assert.NoError(t, err) + assert.Nil(t, route3) + + // 测试用例4: 未指定 header 或 parameter + canary4 := newTestCanary() + canary4.Spec.AttributeRangeRouting = &flaggerv1.CanaryAttributeRangeRouting{ + Enabled: true, + } + + route4, err := router.generateAttributeRangeRoute(canary4, 10) + assert.NoError(t, err) + assert.Nil(t, route4) +} diff --git a/pkg/utils/ip_routing.go b/pkg/utils/ip_routing.go new file mode 100644 index 000000000..6b28a011c --- /dev/null +++ b/pkg/utils/ip_routing.go @@ -0,0 +1,175 @@ +package utils + +import ( + "crypto/md5" + "crypto/sha256" + "fmt" + "hash/fnv" + "net" + "strings" +) + +type IPRangeCalculator struct { + Strategy string + HashFunction string + SlotCount int +} + +func NewIPRangeCalculator(strategy, hashFunction string, slotCount int) *IPRangeCalculator { + if slotCount == 0 { + slotCount = 1000 // default slot count + } + if hashFunction == "" { + hashFunction = "fnv" // default hash function + } + return &IPRangeCalculator{ + Strategy: strategy, + HashFunction: hashFunction, + SlotCount: slotCount, + } +} + +func (calc *IPRangeCalculator) CalculateIPRanges(percentage int) ([]string, error) { + if percentage < 0 || percentage > 100 { + return nil, fmt.Errorf("percentage must be between 0 and 100") + } + + switch calc.Strategy { + case "consistent-hash": + return calc.calculateConsistentHashRanges(percentage) + case "range-based": + return calc.calculateRangeBasedRanges(percentage) + default: + return calc.calculateConsistentHashRanges(percentage) + } +} + +func (calc *IPRangeCalculator) calculateConsistentHashRanges(percentage int) ([]string, error) { + targetSlots := (calc.SlotCount * percentage) / 100 + if targetSlots == 0 && percentage > 0 { + targetSlots = 1 + } + + var ranges []string + for i := 0; i < targetSlots; i++ { + ranges = append(ranges, fmt.Sprintf("slot-%d", i)) + } + + return ranges, nil +} + +func (calc *IPRangeCalculator) calculateRangeBasedRanges(percentage int) ([]string, error) { + var ranges []string + + subnetsNeeded := (256 * percentage) / 100 + if subnetsNeeded == 0 && percentage > 0 { + subnetsNeeded = 1 + } + + for i := 0; i < subnetsNeeded && i < 256; i++ { + ranges = append(ranges, fmt.Sprintf("10.0.%d.0/24", i)) + } + + return ranges, nil +} + +func (calc *IPRangeCalculator) HashIP(ip string) (int, error) { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return 0, fmt.Errorf("invalid IP address: %s", ip) + } + + var hashValue uint64 + switch calc.HashFunction { + case "fnv": + hasher := fnv.New64a() + hasher.Write(parsedIP) + hashValue = hasher.Sum64() + case "md5": + hasher := md5.New() + hasher.Write(parsedIP) + hashBytes := hasher.Sum(nil) + hashValue = uint64(hashBytes[0])<<56 | uint64(hashBytes[1])<<48 | uint64(hashBytes[2])<<40 | uint64(hashBytes[3])<<32 | + uint64(hashBytes[4])<<24 | uint64(hashBytes[5])<<16 | uint64(hashBytes[6])<<8 | uint64(hashBytes[7]) + case "sha256": + hasher := sha256.New() + hasher.Write(parsedIP) + hashBytes := hasher.Sum(nil) + hashValue = uint64(hashBytes[0])<<56 | uint64(hashBytes[1])<<48 | uint64(hashBytes[2])<<40 | uint64(hashBytes[3])<<32 | + uint64(hashBytes[4])<<24 | uint64(hashBytes[5])<<16 | uint64(hashBytes[6])<<8 | uint64(hashBytes[7]) + default: + hasher := fnv.New64a() + hasher.Write(parsedIP) + hashValue = hasher.Sum64() + } + + return int(hashValue % uint64(calc.SlotCount)), nil +} + +func (calc *IPRangeCalculator) ShouldRouteToCanary(ip string, percentage int) (bool, error) { + if percentage == 0 { + return false, nil + } + if percentage >= 100 { + return true, nil + } + + switch calc.Strategy { + case "consistent-hash": + slot, err := calc.HashIP(ip) + if err != nil { + return false, err + } + targetSlots := (calc.SlotCount * percentage) / 100 + return slot < targetSlots, nil + case "range-based": + return calc.isIPInRanges(ip, percentage) + default: + return calc.ShouldRouteToCanary(ip, percentage) + } +} + +func (calc *IPRangeCalculator) isIPInRanges(ip string, percentage int) (bool, error) { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return false, fmt.Errorf("invalid IP address: %s", ip) + } + + ranges, err := calc.calculateRangeBasedRanges(percentage) + if err != nil { + return false, err + } + + for _, cidr := range ranges { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if network.Contains(parsedIP) { + return true, nil + } + } + + return false, nil +} + +func (calc *IPRangeCalculator) GenerateHashRangeRegex(percentage int) (string, error) { + if percentage == 0 { + return "^$", nil // Match nothing + } + if percentage >= 100 { + return ".*", nil // Match everything + } + + targetSlots := (calc.SlotCount * percentage) / 100 + if targetSlots == 0 { + targetSlots = 1 + } + + var patterns []string + for i := 0; i < targetSlots; i++ { + patterns = append(patterns, fmt.Sprintf("slot-%d", i)) + } + + return fmt.Sprintf("^(%s)$", strings.Join(patterns, "|")), nil +} diff --git a/pkg/utils/ip_routing_test.go b/pkg/utils/ip_routing_test.go new file mode 100644 index 000000000..c885790b0 --- /dev/null +++ b/pkg/utils/ip_routing_test.go @@ -0,0 +1,144 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewIPRangeCalculator(t *testing.T) { + calc := NewIPRangeCalculator("consistent-hash", "fnv", 1000) + assert.Equal(t, "consistent-hash", calc.Strategy) + assert.Equal(t, "fnv", calc.HashFunction) + assert.Equal(t, 1000, calc.SlotCount) + + calc2 := NewIPRangeCalculator("", "", 0) + assert.Equal(t, "fnv", calc2.HashFunction) + assert.Equal(t, 1000, calc2.SlotCount) +} + +func TestHashIP(t *testing.T) { + calc := NewIPRangeCalculator("consistent-hash", "fnv", 1000) + + hash, err := calc.HashIP("192.168.1.1") + require.NoError(t, err) + assert.GreaterOrEqual(t, hash, 0) + assert.Less(t, hash, 1000) + + _, err = calc.HashIP("invalid-ip") + assert.Error(t, err) + + hash1, _ := calc.HashIP("192.168.1.1") + hash2, _ := calc.HashIP("192.168.1.1") + assert.Equal(t, hash1, hash2) + + hash3, _ := calc.HashIP("192.168.1.2") + assert.NotEqual(t, hash1, hash3) +} + +func TestShouldRouteToCanary(t *testing.T) { + calc := NewIPRangeCalculator("consistent-hash", "fnv", 1000) + + shouldRoute, err := calc.ShouldRouteToCanary("192.168.1.1", 0) + require.NoError(t, err) + assert.False(t, shouldRoute) + + shouldRoute, err = calc.ShouldRouteToCanary("192.168.1.1", 100) + require.NoError(t, err) + assert.True(t, shouldRoute) + + shouldRoute1, err := calc.ShouldRouteToCanary("192.168.1.1", 50) + require.NoError(t, err) + shouldRoute2, err := calc.ShouldRouteToCanary("192.168.1.1", 50) + require.NoError(t, err) + assert.Equal(t, shouldRoute1, shouldRoute2) +} + +func TestGenerateHashRangeRegex(t *testing.T) { + calc := NewIPRangeCalculator("consistent-hash", "fnv", 1000) + + regex, err := calc.GenerateHashRangeRegex(0) + require.NoError(t, err) + assert.Equal(t, "^$", regex) + + regex, err = calc.GenerateHashRangeRegex(100) + require.NoError(t, err) + assert.Equal(t, ".*", regex) + + regex, err = calc.GenerateHashRangeRegex(10) + require.NoError(t, err) + assert.Contains(t, regex, "slot-0") + assert.Contains(t, regex, "slot-99") + assert.NotContains(t, regex, "slot-100") +} + +func TestCalculateIPRanges(t *testing.T) { + calc := NewIPRangeCalculator("consistent-hash", "fnv", 1000) + + ranges, err := calc.CalculateIPRanges(10) + require.NoError(t, err) + assert.Len(t, ranges, 100) // 10% of 1000 slots + + _, err = calc.CalculateIPRanges(-1) + assert.Error(t, err) + + _, err = calc.CalculateIPRanges(101) + assert.Error(t, err) +} + +func TestHashIPDifferentFunctions(t *testing.T) { + ip := "192.168.1.1" + + calcFNV := NewIPRangeCalculator("consistent-hash", "fnv", 1000) + calcMD5 := NewIPRangeCalculator("consistent-hash", "md5", 1000) + calcSHA256 := NewIPRangeCalculator("consistent-hash", "sha256", 1000) + + hashFNV, err := calcFNV.HashIP(ip) + require.NoError(t, err) + + hashMD5, err := calcMD5.HashIP(ip) + require.NoError(t, err) + + hashSHA256, err := calcSHA256.HashIP(ip) + require.NoError(t, err) + + assert.NotEqual(t, hashFNV, hashMD5) + assert.NotEqual(t, hashFNV, hashSHA256) + assert.NotEqual(t, hashMD5, hashSHA256) + + assert.GreaterOrEqual(t, hashFNV, 0) + assert.Less(t, hashFNV, 1000) + assert.GreaterOrEqual(t, hashMD5, 0) + assert.Less(t, hashMD5, 1000) + assert.GreaterOrEqual(t, hashSHA256, 0) + assert.Less(t, hashSHA256, 1000) +} + +func TestRangeBasedStrategy(t *testing.T) { + calc := NewIPRangeCalculator("range-based", "fnv", 1000) + + ranges, err := calc.CalculateIPRanges(10) + require.NoError(t, err) + assert.Greater(t, len(ranges), 0) + + for _, r := range ranges { + assert.Contains(t, r, "/24") + assert.Contains(t, r, "10.0.") + } +} + +func TestIsIPInRanges(t *testing.T) { + calc := NewIPRangeCalculator("range-based", "fnv", 1000) + + inRange, err := calc.isIPInRanges("10.0.0.1", 10) + require.NoError(t, err) + assert.True(t, inRange) + + inRange, err = calc.isIPInRanges("192.168.1.1", 10) + require.NoError(t, err) + assert.False(t, inRange) + + _, err = calc.isIPInRanges("invalid-ip", 10) + assert.Error(t, err) +} diff --git a/test/istio/ip-routing.yaml b/test/istio/ip-routing.yaml new file mode 100644 index 000000000..dffd63c26 --- /dev/null +++ b/test/istio/ip-routing.yaml @@ -0,0 +1,156 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-ip-routing +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: podinfo + namespace: test-ip-routing +spec: + selector: + matchLabels: + app: podinfo + template: + metadata: + labels: + app: podinfo + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9898" + spec: + containers: + - name: podinfod + image: ghcr.io/stefanprodan/podinfo:6.0.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 9898 + protocol: TCP + env: + - name: PODINFO_UI_COLOR + value: green + command: + - ./podinfo + - --port=9898 + - --port-metrics=9797 + - --grpc-port=9999 + - --grpc-service-name=podinfo + - --level=info + - --random-delay=false + - --random-error=false + livenessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/healthz + initialDelaySeconds: 5 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/readyz + initialDelaySeconds: 5 + timeoutSeconds: 5 + resources: + limits: + cpu: 2000m + memory: 512Mi + requests: + cpu: 100m + memory: 32Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: podinfo + namespace: test-ip-routing +spec: + selector: + app: podinfo + ports: + - name: http + port: 9898 + targetPort: 9898 + protocol: TCP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: podinfo + namespace: test-ip-routing +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: podinfo + minReplicas: 1 + maxReplicas: 2 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 99 +--- +apiVersion: flagger.app/v1beta1 +kind: Canary +metadata: + name: podinfo + namespace: test-ip-routing +spec: + provider: istio + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: podinfo + progressDeadlineSeconds: 60 + service: + port: 9898 + portDiscovery: true + gateways: + - istio-system/public-gateway + hosts: + - app.example.com + ipRangeRouting: + enabled: true + strategy: consistent-hash + initialPercentage: 10 + stepPercentage: 10 + maxPercentage: 50 + hashFunction: fnv + slotCount: 1000 + analysis: + interval: 15s + threshold: 10 + maxWeight: 50 + stepWeight: 10 + metrics: + - name: request-success-rate + thresholdRange: + min: 99 + interval: 1m + - name: request-duration + thresholdRange: + max: 500 + interval: 1m + webhooks: + - name: acceptance-test + type: pre-rollout + url: http://flagger-loadtester.test/ + timeout: 30s + metadata: + type: bash + cmd: "curl -sd 'test' http://podinfo-canary.test-ip-routing:9898/token | grep token" + - name: load-test + url: http://flagger-loadtester.test/ + timeout: 5s + metadata: + cmd: "hey -z 2m -q 10 -c 2 http://podinfo.test-ip-routing:9898/" \ No newline at end of file diff --git a/test/kubernetes/attribute-routing.yaml b/test/kubernetes/attribute-routing.yaml new file mode 100644 index 000000000..2d911e721 --- /dev/null +++ b/test/kubernetes/attribute-routing.yaml @@ -0,0 +1,153 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-attribute-routing +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: podinfo + namespace: test-attribute-routing +spec: + selector: + matchLabels: + app: podinfo + template: + metadata: + labels: + app: podinfo + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9898" + spec: + containers: + - name: podinfod + image: ghcr.io/stefanprodan/podinfo:6.0.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 9898 + protocol: TCP + env: + - name: PODINFO_UI_COLOR + value: blue + command: + - ./podinfo + - --port=9898 + - --port-metrics=9797 + - --grpc-port=9999 + - --grpc-service-name=podinfo + - --level=info + - --random-delay=false + - --random-error=false + livenessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/healthz + initialDelaySeconds: 5 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/readyz + initialDelaySeconds: 5 + timeoutSeconds: 5 + resources: + limits: + cpu: 2000m + memory: 512Mi + requests: + cpu: 100m + memory: 32Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: podinfo + namespace: test-attribute-routing +spec: + selector: + app: podinfo + ports: + - name: http + port: 9898 + targetPort: 9898 + protocol: TCP +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: podinfo + namespace: test-attribute-routing +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: podinfo + minReplicas: 1 + maxReplicas: 2 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 99 +--- +apiVersion: flagger.app/v1beta1 +kind: Canary +metadata: + name: podinfo + namespace: test-attribute-routing +spec: + provider: kubernetes + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: podinfo + progressDeadlineSeconds: 60 + service: + port: 9898 + portDiscovery: true + attributeRangeRouting: + enabled: true + headerName: "X-User-ID" + strategy: consistent-hash + initialPercentage: 10 + stepPercentage: 10 + maxPercentage: 50 + hashFunction: fnv + slotCount: 1000 + analysis: + interval: 15s + threshold: 10 + maxWeight: 50 + stepWeight: 10 + metrics: + - name: request-success-rate + thresholdRange: + min: 99 + interval: 1m + - name: request-duration + thresholdRange: + max: 500 + interval: 1m + webhooks: + - name: acceptance-test + type: pre-rollout + url: http://flagger-loadtester.test/ + timeout: 30s + metadata: + type: bash + cmd: "curl -sd 'test' http://podinfo-canary.test-attribute-routing:9898/token | grep token" + - name: load-test + url: http://flagger-loadtester.test/ + timeout: 5s + metadata: + cmd: "hey -z 2m -q 10 -c 2 http://podinfo.test-attribute-routing:9898/" \ No newline at end of file