From e8e36bd9d5142dcf238e74c20cf2264543966484 Mon Sep 17 00:00:00 2001 From: Kim Min <291271447@qq.com> Date: Sun, 1 Jul 2018 17:26:03 +0800 Subject: [PATCH] Specify backend servers' weight via annotation for kubernetes --- docs/configuration/backends/kubernetes.md | 37 +- provider/kubernetes/annotations.go | 1 + provider/kubernetes/kubernetes.go | 16 +- provider/kubernetes/kubernetes_test.go | 115 +++++ provider/kubernetes/percentage.go | 47 ++ provider/kubernetes/percentage_test.go | 196 ++++++++ provider/kubernetes/weight_allocator.go | 190 ++++++++ provider/kubernetes/weight_allocator_test.go | 455 +++++++++++++++++++ 8 files changed, 1055 insertions(+), 2 deletions(-) create mode 100644 provider/kubernetes/percentage.go create mode 100644 provider/kubernetes/percentage_test.go create mode 100644 provider/kubernetes/weight_allocator.go create mode 100644 provider/kubernetes/weight_allocator_test.go diff --git a/docs/configuration/backends/kubernetes.md b/docs/configuration/backends/kubernetes.md index fe66aa245..621ec1740 100644 --- a/docs/configuration/backends/kubernetes.md +++ b/docs/configuration/backends/kubernetes.md @@ -155,9 +155,11 @@ The following general annotations are applicable on the Ingress object: | `traefik.ingress.kubernetes.io/redirect-replacement: http://mydomain/$1` | Redirect to another URL for that frontend. Must be set with `traefik.ingress.kubernetes.io/redirect-regex`. | | `traefik.ingress.kubernetes.io/rewrite-target: /users` | Replaces each matched Ingress path with the specified one, and adds the old path to the `X-Replaced-Path` header. | | `traefik.ingress.kubernetes.io/rule-type: PathPrefixStrip` | Override the default frontend rule type. Default: `PathPrefix`. | -| `traefik.ingress.kubernetes.io/whitelist-source-range: "1.2.3.0/24, fe80::/16"` | A comma-separated list of IP ranges permitted for access. all source IPs are permitted if the list is empty or a single range is ill-formatted. Please note, you may have to set `service.spec.externalTrafficPolicy` to the value `Local` to preserve the source IP of the request for filtering. Please see [this link](https://kubernetes.io/docs/tutorials/services/source-ip/) for more information.| +| `traefik.ingress.kubernetes.io/whitelist-source-range: "1.2.3.0/24, fe80::/16"` | A comma-separated list of IP ranges permitted for access (6). | | `ingress.kubernetes.io/whitelist-x-forwarded-for: "true"` | Use `X-Forwarded-For` header as valid source of IP for the white list. | | `traefik.ingress.kubernetes.io/app-root: "/index.html"` | Redirects all requests for `/` to the defined path. (4) | +| `traefik.ingress.kubernetes.io/service-weights: ` | Set ingress backend weights specified as percentage or decimal numbers in YAML. (5) | + <1> `traefik.ingress.kubernetes.io/error-pages` example: @@ -205,6 +207,39 @@ Non-root paths will not be affected by this annotation and handled normally. This annotation may not be combined with the `ReplacePath` rule type or any other annotation leveraging that rule type. Trying to do so leads to an error and the corresponding Ingress object being ignored. +<5> `traefik.ingress.kubernetes.io/service-weights`: +Service weights enable to split traffic across multiple backing services in a fine-grained manner. +A canonical use case are canary releases where a new deployment starts to receive a small percentage of traffic (e.g., 1%) and steadily increases over time as confidence in the new deployment improves. + +Example: + +```yaml +service_backend1: 12.50% +service_backend2: 12.50% +service_backend3: 75 # Same as 75%, the percentage sign is optional +``` + +A single service backend definition may be omitted; in this case, Traefik auto-completes that service backend to 100% automatically. +Conveniently, users need not bother to compute the percentage remainder for a main service backend. +For instance, in the example above `service_backend3` does not need to be specified to be assigned 75%. + +!!! note + For each service weight given, the Ingress specification must include a backend item with the corresponding `serviceName` and (if given) matching path. + +Currently, 3 decimal places for the weight are supported. +An attempt to exceed the precision should be avoided as it may lead to percentage computation flaws and, in consequence, Ingress parsing errors. + +For each path definition, this annotation will fail if: + +- the sum of backend weights exceeds 100% or +- the sum of backend weights is less than 100% without one or more omitted backends + +<6> `traefik.ingress.kubernetes.io/whitelist-source-range`: +All source IPs are permitted if the list is empty or a single range is ill-formatted. +Please note, you may have to set `service.spec.externalTrafficPolicy` to the value `Local` to preserve the source IP of the request for filtering. +Please see [this link](https://kubernetes.io/docs/tutorials/services/source-ip/) for more information. + + !!! note Please note that `traefik.ingress.kubernetes.io/redirect-regex` and `traefik.ingress.kubernetes.io/redirect-replacement` do not have to be set if `traefik.ingress.kubernetes.io/redirect-entry-point` is defined for the redirection (they will not be used in this case). diff --git a/provider/kubernetes/annotations.go b/provider/kubernetes/annotations.go index 17eb83f85..5feec1598 100644 --- a/provider/kubernetes/annotations.go +++ b/provider/kubernetes/annotations.go @@ -31,6 +31,7 @@ const ( annotationKubernetesErrorPages = "ingress.kubernetes.io/error-pages" annotationKubernetesBuffering = "ingress.kubernetes.io/buffering" annotationKubernetesAppRoot = "ingress.kubernetes.io/app-root" + annotationKubernetesServiceWeights = "ingress.kubernetes.io/service-weights" annotationKubernetesSSLForceHost = "ingress.kubernetes.io/ssl-force-host" annotationKubernetesSSLRedirect = "ingress.kubernetes.io/ssl-redirect" diff --git a/provider/kubernetes/kubernetes.go b/provider/kubernetes/kubernetes.go index 8a6f3d019..d92b0fc5e 100644 --- a/provider/kubernetes/kubernetes.go +++ b/provider/kubernetes/kubernetes.go @@ -184,6 +184,18 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) } templateObjects.TLS = append(templateObjects.TLS, tlsSection...) + var weightAllocator weightAllocator = &defaultWeightAllocator{} + annotationPercentageWeights := getAnnotationName(i.Annotations, annotationKubernetesServiceWeights) + if _, ok := i.Annotations[annotationPercentageWeights]; ok { + fractionalAllocator, err := newFractionalWeightAllocator(i, k8sClient) + if err != nil { + log.Errorf("failed to create fractional weight allocator for ingress %s/%s: %v", i.Namespace, i.Name, err) + continue + } + log.Debugf("Created custom weight allocator for %s/%s: %s", i.Namespace, i.Name, fractionalAllocator) + weightAllocator = fractionalAllocator + } + for _, r := range i.Spec.Rules { if r.HTTP == nil { log.Warn("Error in ingress: HTTP is nil") @@ -274,6 +286,7 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) templateObjects.Backends[baseName].Buffering = getBuffering(service) protocol := label.DefaultProtocol + for _, port := range service.Spec.Ports { if equalPorts(port, pa.Backend.ServicePort) { if port.Port == 443 || strings.HasPrefix(port.Name, "https") { @@ -319,9 +332,10 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) if address.TargetRef != nil && address.TargetRef.Name != "" { name = address.TargetRef.Name } + templateObjects.Backends[baseName].Servers[name] = types.Server{ URL: url, - Weight: label.DefaultWeight, + Weight: weightAllocator.getWeight(r.Host, pa.Path, pa.Backend.ServiceName), } } } diff --git a/provider/kubernetes/kubernetes_test.go b/provider/kubernetes/kubernetes_test.go index 5974e5139..eacd36d26 100644 --- a/provider/kubernetes/kubernetes_test.go +++ b/provider/kubernetes/kubernetes_test.go @@ -2269,7 +2269,122 @@ func TestProviderUpdateIngressStatus(t *testing.T) { } }) } +} +func TestPercentageWeightServiceAnnotation(t *testing.T) { + ingresses := []*extensionsv1beta1.Ingress{ + buildIngress( + iAnnotation(annotationKubernetesServiceWeights, ` +service1: 10% +`), + iNamespace("testing"), + iRules( + iRule( + iHost("host1"), + iPaths( + onePath(iPath("/foo"), iBackend("service1", intstr.FromString("8080"))), + onePath(iPath("/foo"), iBackend("service2", intstr.FromString("7070"))), + onePath(iPath("/bar"), iBackend("service2", intstr.FromString("7070"))), + )), + ), + ), + } + services := []*corev1.Service{ + buildService( + sName("service1"), + sNamespace("testing"), + sUID("1"), + sSpec( + clusterIP("10.0.0.1"), + sPorts(sPort(8080, "")), + ), + ), + buildService( + sName("service2"), + sNamespace("testing"), + sUID("1"), + sSpec( + clusterIP("10.0.0.1"), + sPorts(sPort(7070, "")), + ), + ), + } + + endpoints := []*corev1.Endpoints{ + buildEndpoint( + eNamespace("testing"), + eName("service1"), + eUID("1"), + subset( + eAddresses( + eAddress("10.10.0.1"), + eAddress("10.10.0.2"), + ), + ePorts(ePort(8080, "")), + ), + ), + buildEndpoint( + eNamespace("testing"), + eName("service2"), + eUID("1"), + subset( + eAddresses( + eAddress("10.10.0.3"), + eAddress("10.10.0.4"), + ), + ePorts(ePort(7070, "")), + ), + ), + } + + watchChan := make(chan interface{}) + client := clientMock{ + ingresses: ingresses, + services: services, + endpoints: endpoints, + watchChan: watchChan, + } + provider := Provider{} + + actual, err := provider.loadIngresses(client) + require.NoError(t, err, "error loading ingresses") + + expected := buildConfiguration( + backends( + backend("host1/foo", + servers( + server("http://10.10.0.1:8080", weight(int(newPercentageValueFromFloat64(0.05)))), + server("http://10.10.0.2:8080", weight(int(newPercentageValueFromFloat64(0.05)))), + server("http://10.10.0.3:7070", weight(int(newPercentageValueFromFloat64(0.45)))), + server("http://10.10.0.4:7070", weight(int(newPercentageValueFromFloat64(0.45)))), + ), + lbMethod("wrr"), + ), + backend("host1/bar", + servers( + server("http://10.10.0.3:7070", weight(int(newPercentageValueFromFloat64(0.5)))), + server("http://10.10.0.4:7070", weight(int(newPercentageValueFromFloat64(0.5)))), + ), + lbMethod("wrr"), + ), + ), + frontends( + frontend("host1/bar", + passHostHeader(), + routes( + route("/bar", "PathPrefix:/bar"), + route("host1", "Host:host1")), + ), + frontend("host1/foo", + passHostHeader(), + routes( + route("/foo", "PathPrefix:/foo"), + route("host1", "Host:host1")), + ), + ), + ) + + assert.Equal(t, expected, actual, "error loading percentage weight annotation") } func TestProviderNewK8sInClusterClient(t *testing.T) { diff --git a/provider/kubernetes/percentage.go b/provider/kubernetes/percentage.go new file mode 100644 index 000000000..c27f9dd6d --- /dev/null +++ b/provider/kubernetes/percentage.go @@ -0,0 +1,47 @@ +package kubernetes + +import ( + "strconv" + "strings" +) + +const defaultPercentageValuePrecision = 3 + +// percentageValue is int64 form of percentage value with 10^-3 precision. +type percentageValue int64 + +// toFloat64 returns its decimal float64 value. +func (v percentageValue) toFloat64() float64 { + return float64(v) / (1000 * 100) +} + +func (v percentageValue) computeWeight(count int) int { + if count == 0 { + return 0 + } + return int(float64(v) / float64(count)) +} + +// String returns its string form of percentage value. +func (v percentageValue) String() string { + return strconv.FormatFloat(v.toFloat64()*100, 'f', defaultPercentageValuePrecision, 64) + "%" +} + +// newPercentageValueFromString tries to read percentage value from string, it can be either "1.1" or "1.1%", "6%". +// It will lose the extra precision if there are more digits after decimal point. +func newPercentageValueFromString(rawValue string) (percentageValue, error) { + if strings.HasSuffix(rawValue, "%") { + rawValue = rawValue[:len(rawValue)-1] + } + value, err := strconv.ParseFloat(rawValue, 64) + if err != nil { + return 0, err + } + + return newPercentageValueFromFloat64(value) / 100, nil +} + +// newPercentageValueFromFloat64 reads percentage value from float64 +func newPercentageValueFromFloat64(f float64) percentageValue { + return percentageValue(f * (1000 * 100)) +} diff --git a/provider/kubernetes/percentage_test.go b/provider/kubernetes/percentage_test.go new file mode 100644 index 000000000..1555e92d7 --- /dev/null +++ b/provider/kubernetes/percentage_test.go @@ -0,0 +1,196 @@ +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPercentageValueFromFloat64(t *testing.T) { + testCases := []struct { + desc string + value float64 + expectedString string + expectedFloat64 float64 + }{ + { + value: 0.01, + expectedString: "1.000%", + expectedFloat64: 0.01, + }, + { + value: 0.5, + expectedString: "50.000%", + expectedFloat64: 0.5, + }, + { + value: 0.99, + expectedString: "99.000%", + expectedFloat64: 0.99, + }, + { + value: 0.99999, + expectedString: "99.999%", + expectedFloat64: 0.99999, + }, + { + value: -0.99999, + expectedString: "-99.999%", + expectedFloat64: -0.99999, + }, + { + value: -0.9999999, + expectedString: "-99.999%", + expectedFloat64: -0.99999, + }, + { + value: 0, + expectedString: "0.000%", + expectedFloat64: 0, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + pvFromFloat64 := newPercentageValueFromFloat64(test.value) + + assert.Equal(t, test.expectedString, pvFromFloat64.String(), "percentage string value mismatched") + assert.Equal(t, test.expectedFloat64, pvFromFloat64.toFloat64(), "percentage float64 value mismatched") + }) + } +} + +func TestNewPercentageValueFromString(t *testing.T) { + testCases := []struct { + desc string + value string + expectError bool + expectedString string + expectedFloat64 float64 + }{ + { + value: "1%", + expectError: false, + expectedString: "1.000%", + expectedFloat64: 0.01, + }, + { + value: "0.5", + expectError: false, + expectedString: "0.500%", + expectedFloat64: 0.005, + }, + { + value: "99%", + expectError: false, + expectedString: "99.000%", + expectedFloat64: 0.99, + }, + { + value: "99.9%", + expectError: false, + expectedString: "99.900%", + expectedFloat64: 0.999, + }, + { + value: "-99.9%", + expectError: false, + expectedString: "-99.900%", + expectedFloat64: -0.999, + }, + { + value: "-99.99999%", + expectError: false, + expectedString: "-99.999%", + expectedFloat64: -0.99999, + }, + { + value: "0%", + expectError: false, + expectedString: "0.000%", + expectedFloat64: 0, + }, + { + value: "%", + expectError: true, + }, + { + value: "foo", + expectError: true, + }, + { + value: "", + expectError: true, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + pvFromString, err := newPercentageValueFromString(test.value) + + if test.expectError { + require.Error(t, err, "expecting error but not happening") + } else { + require.NoError(t, err, "fail to parse percentage value") + + assert.Equal(t, test.expectedString, pvFromString.String(), "percentage string value mismatched") + assert.Equal(t, test.expectedFloat64, pvFromString.toFloat64(), "percentage float64 value mismatched") + } + }) + } +} + +func TestNewPercentageValue(t *testing.T) { + testCases := []struct { + desc string + stringValue string + floatValue float64 + }{ + { + desc: "percentage", + stringValue: "1%", + floatValue: 0.01, + }, + { + desc: "decimal", + stringValue: "0.5", + floatValue: 0.005, + }, + { + desc: "negative percentage", + stringValue: "-99.999%", + floatValue: -0.99999, + }, + { + desc: "negative decimal", + stringValue: "-0.99999", + floatValue: -0.0099999, + }, + { + desc: "zero", + stringValue: "0%", + floatValue: 0, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + pvFromString, err := newPercentageValueFromString(test.stringValue) + require.NoError(t, err, "fail to parse percentage value") + + pvFromFloat64 := newPercentageValueFromFloat64(test.floatValue) + + assert.Equal(t, pvFromString, pvFromFloat64) + }) + } +} diff --git a/provider/kubernetes/weight_allocator.go b/provider/kubernetes/weight_allocator.go new file mode 100644 index 000000000..1b13dbe12 --- /dev/null +++ b/provider/kubernetes/weight_allocator.go @@ -0,0 +1,190 @@ +package kubernetes + +import ( + "fmt" + "sort" + "strings" + + "github.com/containous/traefik/provider/label" + "gopkg.in/yaml.v2" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" +) + +type weightAllocator interface { + getWeight(host, path, serviceName string) int +} + +var _ weightAllocator = &defaultWeightAllocator{} +var _ weightAllocator = &fractionalWeightAllocator{} + +type defaultWeightAllocator struct{} + +func (d *defaultWeightAllocator) getWeight(host, path, serviceName string) int { + return label.DefaultWeight +} + +type ingressService struct { + host string + path string + service string +} + +type fractionalWeightAllocator map[ingressService]int + +// String returns a string representation as service name / percentage tuples +// sorted by service names. +// Example: [foo-svc: 30.000% bar-svc: 70.000%] +func (f *fractionalWeightAllocator) String() string { + var sorted []ingressService + for ingServ := range map[ingressService]int(*f) { + sorted = append(sorted, ingServ) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].service < sorted[j].service + }) + + var res []string + for _, ingServ := range sorted { + res = append(res, fmt.Sprintf("%s: %s", ingServ.service, percentageValue(map[ingressService]int(*f)[ingServ]))) + } + return fmt.Sprintf("[%s]", strings.Join(res, " ")) +} + +func newFractionalWeightAllocator(ingress *extensionsv1beta1.Ingress, client Client) (*fractionalWeightAllocator, error) { + servicePercentageWeights, err := getServicesPercentageWeights(ingress) + if err != nil { + return nil, err + } + + serviceInstanceCounts, err := getServiceInstanceCounts(ingress, client) + if err != nil { + return nil, err + } + + serviceWeights := map[ingressService]int{} + + for _, rule := range ingress.Spec.Rules { + // key: rule path string + // value: service names + fractionalPathServices := map[string][]string{} + + // key: rule path string + // value: fractional percentage weight + fractionalPathWeights := map[string]percentageValue{} + + for _, pa := range rule.HTTP.Paths { + if _, ok := fractionalPathWeights[pa.Path]; !ok { + fractionalPathWeights[pa.Path] = newPercentageValueFromFloat64(1) + } + + if weight, ok := servicePercentageWeights[pa.Backend.ServiceName]; ok { + ingSvc := ingressService{ + host: rule.Host, + path: pa.Path, + service: pa.Backend.ServiceName, + } + + serviceWeights[ingSvc] = weight.computeWeight(serviceInstanceCounts[ingSvc]) + + fractionalPathWeights[pa.Path] -= weight + + if fractionalPathWeights[pa.Path].toFloat64() < 0 { + assignedWeight := newPercentageValueFromFloat64(1) - fractionalPathWeights[pa.Path] + return nil, fmt.Errorf("percentage value %s must not exceed 100%%", assignedWeight.String()) + } + } else { + fractionalPathServices[pa.Path] = append(fractionalPathServices[pa.Path], pa.Backend.ServiceName) + } + } + + for pa, fractionalWeight := range fractionalPathWeights { + fractionalServices := fractionalPathServices[pa] + + if len(fractionalServices) == 0 { + if fractionalWeight > 0 { + assignedWeight := newPercentageValueFromFloat64(1) - fractionalWeight + return nil, fmt.Errorf("the sum of weights(%s) in the path %s%s must be 100%% when no omitted fractional service left", assignedWeight.String(), rule.Host, pa) + } + continue + } + + totalFractionalInstanceCount := 0 + for _, svc := range fractionalServices { + totalFractionalInstanceCount += serviceInstanceCounts[ingressService{ + host: rule.Host, + path: pa, + service: svc, + }] + } + + for _, svc := range fractionalServices { + ingSvc := ingressService{ + host: rule.Host, + path: pa, + service: svc, + } + serviceWeights[ingSvc] = fractionalWeight.computeWeight(totalFractionalInstanceCount) + } + } + } + + allocator := fractionalWeightAllocator(serviceWeights) + return &allocator, nil +} + +func (f *fractionalWeightAllocator) getWeight(host, path, serviceName string) int { + return map[ingressService]int(*f)[ingressService{ + host: host, + path: path, + service: serviceName, + }] +} + +func getServicesPercentageWeights(ingress *extensionsv1beta1.Ingress) (map[string]percentageValue, error) { + percentageWeight := make(map[string]string) + + annotationPercentageWeights := getAnnotationName(ingress.Annotations, annotationKubernetesServiceWeights) + if err := yaml.Unmarshal([]byte(ingress.Annotations[annotationPercentageWeights]), percentageWeight); err != nil { + return nil, err + } + + servicesPercentageWeights := make(map[string]percentageValue) + for serviceName, percentageStr := range percentageWeight { + percentageValue, err := newPercentageValueFromString(percentageStr) + if err != nil { + return nil, fmt.Errorf("invalid percentage value %q", percentageStr) + } + + servicesPercentageWeights[serviceName] = percentageValue + } + return servicesPercentageWeights, nil +} + +func getServiceInstanceCounts(ingress *extensionsv1beta1.Ingress, client Client) (map[ingressService]int, error) { + serviceInstanceCounts := map[ingressService]int{} + + for _, rule := range ingress.Spec.Rules { + for _, pa := range rule.HTTP.Paths { + count := 0 + endpoints, exists, err := client.GetEndpoints(ingress.Namespace, pa.Backend.ServiceName) + if err != nil { + return nil, fmt.Errorf("failed to get endpoints %s/%s: %v", ingress.Namespace, pa.Backend.ServiceName, err) + } + if !exists { + return nil, fmt.Errorf("endpoints not found for %s/%s", ingress.Namespace, pa.Backend.ServiceName) + } + + for _, subset := range endpoints.Subsets { + count += len(subset.Addresses) + } + + serviceInstanceCounts[ingressService{ + host: rule.Host, + path: pa.Path, + service: pa.Backend.ServiceName, + }] += count + } + } + + return serviceInstanceCounts, nil +} diff --git a/provider/kubernetes/weight_allocator_test.go b/provider/kubernetes/weight_allocator_test.go new file mode 100644 index 000000000..712d51311 --- /dev/null +++ b/provider/kubernetes/weight_allocator_test.go @@ -0,0 +1,455 @@ +package kubernetes + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestString(t *testing.T) { + pv1 := newPercentageValueFromFloat64(0.5) + pv2 := newPercentageValueFromFloat64(0.2) + pv3 := newPercentageValueFromFloat64(0.3) + f := fractionalWeightAllocator( + map[ingressService]int{ + { + host: "host2", + path: "path2", + service: "service2", + }: int(pv2), + { + host: "host3", + path: "path3", + service: "service3", + }: int(pv3), + { + host: "host1", + path: "path1", + service: "service1", + }: int(pv1), + }, + ) + + expected := fmt.Sprintf("[service1: %s service2: %s service3: %s]", pv1, pv2, pv3) + actual := f.String() + assert.Equal(t, expected, actual) +} + +func TestGetServicesPercentageWeights(t *testing.T) { + testCases := []struct { + desc string + annotationValue string + expectError bool + expectedWeights map[string]percentageValue + }{ + { + desc: "empty annotation", + annotationValue: ``, + expectedWeights: map[string]percentageValue{}, + }, + { + desc: "50% fraction", + annotationValue: ` +service1: 10% +service2: 20% +service3: 20% +`, + expectedWeights: map[string]percentageValue{ + "service1": newPercentageValueFromFloat64(0.1), + "service2": newPercentageValueFromFloat64(0.2), + "service3": newPercentageValueFromFloat64(0.2), + }, + }, + { + desc: "50% fraction with empty fraction", + annotationValue: ` +service1: 10% +service2: 20% +service3: 20% +service4: +`, + expectError: true, + }, + { + desc: "50% fraction float form", + annotationValue: ` +service1: 0.1 +service2: 0.2 +service3: 0.2 +`, + expectedWeights: map[string]percentageValue{ + "service1": newPercentageValueFromFloat64(0.001), + "service2": newPercentageValueFromFloat64(0.002), + "service3": newPercentageValueFromFloat64(0.002), + }, + }, + { + desc: "no fraction", + annotationValue: ` +service1: 10% +service2: 90% +`, + expectedWeights: map[string]percentageValue{ + "service1": newPercentageValueFromFloat64(0.1), + "service2": newPercentageValueFromFloat64(0.9), + }, + }, + { + desc: "extra weight specification", + annotationValue: ` +service1: 90% +service5: 90% +`, + expectedWeights: map[string]percentageValue{ + "service1": newPercentageValueFromFloat64(0.9), + "service5": newPercentageValueFromFloat64(0.9), + }, + }, + { + desc: "malformed annotation", + annotationValue: ` +service1- 90% +service5- 90% +`, + expectError: true, + expectedWeights: nil, + }, + { + desc: "more than one hundred percentaged service", + annotationValue: ` +service1: 100% +service2: 1% +`, + expectedWeights: map[string]percentageValue{ + "service1": newPercentageValueFromFloat64(1), + "service2": newPercentageValueFromFloat64(0.01), + }, + }, + { + desc: "incorrect percentage value", + annotationValue: ` +service1: 1000% +`, + expectedWeights: map[string]percentageValue{ + "service1": newPercentageValueFromFloat64(10), + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + ingress := &extensionsv1beta1.Ingress{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + annotationKubernetesServiceWeights: test.annotationValue, + }, + }, + } + + weights, err := getServicesPercentageWeights(ingress) + + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expectedWeights, weights) + } + }) + } +} + +func TestComputeServiceWeights(t *testing.T) { + client := clientMock{ + endpoints: []*corev1.Endpoints{ + buildEndpoint( + eNamespace("testing"), + eName("service1"), + eUID("1"), + subset( + eAddresses(eAddress("10.10.0.1")), + ePorts(ePort(8080, ""))), + subset( + eAddresses(eAddress("10.21.0.2")), + ePorts(ePort(8080, ""))), + ), + buildEndpoint( + eNamespace("testing"), + eName("service2"), + eUID("2"), + subset( + eAddresses(eAddress("10.10.0.3")), + ePorts(ePort(8080, ""))), + ), + buildEndpoint( + eNamespace("testing"), + eName("service3"), + eUID("3"), + subset( + eAddresses(eAddress("10.10.0.4")), + ePorts(ePort(8080, ""))), + subset( + eAddresses(eAddress("10.21.0.5")), + ePorts(ePort(8080, ""))), + subset( + eAddresses(eAddress("10.21.0.6")), + ePorts(ePort(8080, ""))), + subset( + eAddresses(eAddress("10.21.0.7")), + ePorts(ePort(8080, ""))), + ), + buildEndpoint( + eNamespace("testing"), + eName("service4"), + eUID("4"), + subset( + eAddresses(eAddress("10.10.0.7")), + ePorts(ePort(8080, ""))), + ), + }, + } + + testCases := []struct { + desc string + ingress *extensionsv1beta1.Ingress + expectError bool + expectedWeights map[ingressService]percentageValue + }{ + { + desc: "1 path 2 service", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(annotationKubernetesServiceWeights, ` +service1: 10% +`), + iRules( + iRule(iHost("foo.test"), iPaths( + onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))), + onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))), + )), + ), + ), + expectError: false, + expectedWeights: map[ingressService]percentageValue{ + { + host: "foo.test", + path: "/foo", + service: "service1", + }: newPercentageValueFromFloat64(0.05), + { + host: "foo.test", + path: "/foo", + service: "service2", + }: newPercentageValueFromFloat64(0.90), + }, + }, + { + desc: "2 path 2 service", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(annotationKubernetesServiceWeights, ` +service1: 60% +`), + iRules( + iRule(iHost("foo.test"), iPaths( + onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))), + onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))), + onePath(iPath("/bar"), iBackend("service1", intstr.FromInt(8080))), + onePath(iPath("/bar"), iBackend("service3", intstr.FromInt(8080))), + )), + ), + ), + expectError: false, + expectedWeights: map[ingressService]percentageValue{ + { + host: "foo.test", + path: "/foo", + service: "service1", + }: newPercentageValueFromFloat64(0.30), + { + host: "foo.test", + path: "/foo", + service: "service2", + }: newPercentageValueFromFloat64(0.40), + { + host: "foo.test", + path: "/bar", + service: "service1", + }: newPercentageValueFromFloat64(0.30), + { + host: "foo.test", + path: "/bar", + service: "service3", + }: newPercentageValueFromFloat64(0.10), + }, + }, + { + desc: "2 path 3 service", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(annotationKubernetesServiceWeights, ` +service1: 20% +service3: 20% +`), + iRules( + iRule(iHost("foo.test"), iPaths( + onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))), + onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))), + onePath(iPath("/bar"), iBackend("service2", intstr.FromInt(8080))), + onePath(iPath("/bar"), iBackend("service3", intstr.FromInt(8080))), + )), + ), + ), + expectError: false, + expectedWeights: map[ingressService]percentageValue{ + { + host: "foo.test", + path: "/foo", + service: "service1", + }: newPercentageValueFromFloat64(0.10), + { + host: "foo.test", + path: "/foo", + service: "service2", + }: newPercentageValueFromFloat64(0.80), + { + host: "foo.test", + path: "/bar", + service: "service3", + }: newPercentageValueFromFloat64(0.05), + { + host: "foo.test", + path: "/bar", + service: "service2", + }: newPercentageValueFromFloat64(0.80), + }, + }, + { + desc: "1 path 4 service", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(annotationKubernetesServiceWeights, ` +service1: 20% +service2: 40% +service3: 40% +`), + iRules( + iRule(iHost("foo.test"), iPaths( + onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))), + onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))), + onePath(iPath("/foo"), iBackend("service3", intstr.FromInt(8080))), + onePath(iPath("/foo"), iBackend("service4", intstr.FromInt(8080))), + )), + ), + ), + expectError: false, + expectedWeights: map[ingressService]percentageValue{ + { + host: "foo.test", + path: "/foo", + service: "service1", + }: newPercentageValueFromFloat64(0.10), + { + host: "foo.test", + path: "/foo", + service: "service2", + }: newPercentageValueFromFloat64(0.40), + { + host: "foo.test", + path: "/foo", + service: "service3", + }: newPercentageValueFromFloat64(0.10), + { + host: "foo.test", + path: "/foo", + service: "service4", + }: newPercentageValueFromFloat64(0.00), + }, + }, + { + desc: "2 path no service", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(annotationKubernetesServiceWeights, ` +service1: 20% +service2: 40% +service3: 40% +`), + iRules( + iRule(iHost("foo.test"), iPaths( + onePath(iPath("/foo"), iBackend("noservice", intstr.FromInt(8080))), + onePath(iPath("/bar"), iBackend("noservice", intstr.FromInt(8080))), + )), + ), + ), + expectError: true, + }, + { + desc: "2 path without weight", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(annotationKubernetesServiceWeights, ``), + iRules( + iRule(iHost("foo.test"), iPaths( + onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))), + onePath(iPath("/bar"), iBackend("service2", intstr.FromInt(8080))), + )), + ), + ), + expectError: false, + expectedWeights: map[ingressService]percentageValue{ + { + host: "foo.test", + path: "/foo", + service: "service1", + }: newPercentageValueFromFloat64(0.50), + { + host: "foo.test", + path: "/bar", + service: "service2", + }: newPercentageValueFromFloat64(1.00), + }, + }, + { + desc: "2 path overflow", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(annotationKubernetesServiceWeights, ` +service1: 70% +service2: 80% +`), + iRules( + iRule(iHost("foo.test"), iPaths( + onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))), + onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))), + )), + ), + ), + expectError: true, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + weightAllocator, err := newFractionalWeightAllocator(test.ingress, client) + if test.expectError { + require.Error(t, err) + } else { + for ingSvc, percentage := range test.expectedWeights { + assert.Equal(t, int(percentage), weightAllocator.getWeight(ingSvc.host, ingSvc.path, ingSvc.service)) + } + } + }) + } +}