blob: 05f04f31ec3bf39ea750c3980dc0471178ec41c3 [file]
//go:build e2e
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package e2e
import (
"context"
"fmt"
"maps"
"strings"
"testing"
"github.com/blang/semver/v4"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
annotationSourceCidrs = "service.beta.kubernetes.io/cloudstack-load-balancer-source-cidrs"
annotationHostname = "service.beta.kubernetes.io/cloudstack-load-balancer-hostname"
annotationIPAssociated = "service.beta.kubernetes.io/cloudstack-load-balancer-ip-associated-by-controller" //nolint:gosec
annotationProxyProtocol = "service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol"
annotationStickinessMethod = "service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name"
annotationStickinessParams = "service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param"
)
func TestAnnot_SourceCIDRs(t *testing.T) {
f := NewFramework(t)
svc := f.CreateLBService(func(s *corev1.Service) {
s.Annotations = map[string]string{
annotationSourceCidrs: "10.0.0.0/8,192.168.100.0/24",
}
})
lbName := defaultLoadBalancerName(svc)
f.WaitForIngressIP(svc)
rules := f.WaitForLBRules(lbName, 1)
for _, cidr := range []string{"10.0.0.0/8", "192.168.100.0/24"} {
if !strings.Contains(rules[0].Cidrlist, cidr) {
t.Errorf("rule cidrlist = %q, want it to contain %s", rules[0].Cidrlist, cidr)
}
}
originalRuleID := rules[0].Id
f.UpdateService(svc, func(s *corev1.Service) {
s.Annotations[annotationSourceCidrs] = "172.16.0.0/12"
})
// Assert on the settled rule after the poll, not inside it: a failed poll
// would otherwise mask the in-place-versus-recreate check.
var settledRuleID string
f.Eventually(lbSyncTimeout, lbSyncInterval, "cidr list update to propagate",
func() (bool, error) {
current, err := f.LBRules(lbName)
if err != nil {
return false, err
}
if len(current) != 1 {
return false, fmt.Errorf("saw %d rules, want 1", len(current))
}
if !strings.Contains(current[0].Cidrlist, "172.16.0.0/12") {
return false, fmt.Errorf("cidrlist is %q, want it to contain 172.16.0.0/12",
current[0].Cidrlist)
}
settledRuleID = current[0].Id
return true, nil
})
// >= 4.22 updates the rule in place; older releases delete and recreate it.
inPlace := f.Version.GTE(semver.Version{Major: 4, Minor: 22, Patch: 0})
if inPlace && settledRuleID != originalRuleID {
t.Errorf("expected in-place cidr update on %s (rule ID changed %s -> %s)",
f.Version, originalRuleID, settledRuleID)
}
if !inPlace && settledRuleID == originalRuleID {
t.Errorf("expected rule recreation on %s (rule ID unchanged)", f.Version)
}
}
func TestAnnot_Hostname(t *testing.T) {
f := NewFramework(t)
svc := f.CreateLBService(func(s *corev1.Service) {
s.Annotations = map[string]string{
annotationHostname: "lb.example.com",
}
})
ingress := f.WaitForIngressIP(svc)
if ingress.Hostname != "lb.example.com" {
t.Errorf("ingress hostname = %q, want lb.example.com", ingress.Hostname)
}
if ingress.IP != "" {
t.Errorf("ingress IP = %q, want empty when hostname annotation is set", ingress.IP)
}
}
func TestAnnot_SessionAffinity(t *testing.T) {
f := NewFramework(t)
svc := f.CreateLBService(func(s *corev1.Service) {
s.Spec.SessionAffinity = corev1.ServiceAffinityClientIP
})
lbName := defaultLoadBalancerName(svc)
f.WaitForIngressIP(svc)
rules := f.WaitForLBRules(lbName, 1)
if rules[0].Algorithm != "source" {
t.Errorf("algorithm = %q, want source for sessionAffinity ClientIP", rules[0].Algorithm)
}
f.UpdateService(svc, func(s *corev1.Service) {
s.Spec.SessionAffinity = corev1.ServiceAffinityNone
})
f.Eventually(lbSyncTimeout, lbSyncInterval, "algorithm to revert to roundrobin",
func() (bool, error) {
current, err := f.LBRules(lbName)
if err != nil || len(current) != 1 {
return false, err
}
return current[0].Algorithm == "roundrobin", nil
})
}
// LbCookie follows appProtocol: http, is kept by a sync that changes nothing, is replaced when a
// parameter changes and is removed with the annotations. The rules stay throughout.
func TestAnnot_Stickiness(t *testing.T) {
f := NewFramework(t)
http := "http"
svc := f.CreateLBService(func(s *corev1.Service) {
s.Annotations = map[string]string{
annotationStickinessMethod: "LbCookie",
annotationStickinessParams: "cookie-name=SERVERID,nocache=true",
}
s.Spec.Ports = []corev1.ServicePort{
{Name: "http", Port: 80, Protocol: corev1.ProtocolTCP, AppProtocol: &http},
{Name: "alt", Port: 8080, Protocol: corev1.ProtocolTCP},
}
})
lbName := defaultLoadBalancerName(svc)
f.WaitForIngressIP(svc)
rules := ruleIDsByPort(f.WaitForLBRules(lbName, 2))
cookie := map[string]string{"cookie-name": "SERVERID", "nocache": "true"}
policy := f.WaitForStickinessPolicy(rules["80"], "LbCookie", cookie)
if policy.Description != stickinessPolicyMarker {
t.Errorf("policy description = %q, want the controller's marker", policy.Description)
}
f.WaitForNoStickinessPolicy(rules["8080"])
// Recreating an unchanged policy on every sync would reset affinity each time.
f.ResyncAndWait(svc)
if again := f.WaitForStickinessPolicy(rules["80"], "LbCookie", cookie); again.Id != policy.Id {
t.Errorf("policy replaced %s -> %s by a reconcile that changed nothing", policy.Id, again.Id)
}
f.UpdateService(svc, func(s *corev1.Service) {
s.Spec.Ports[0].AppProtocol = nil
s.Spec.Ports[1].AppProtocol = &http
})
moved := f.WaitForStickinessPolicy(rules["8080"], "LbCookie", cookie)
f.WaitForNoStickinessPolicy(rules["80"])
f.UpdateService(svc, func(s *corev1.Service) {
s.Annotations[annotationStickinessParams] = "cookie-name=JSESSIONID"
})
if replaced := f.WaitForStickinessPolicy(rules["8080"], "LbCookie", map[string]string{"cookie-name": "JSESSIONID"}); replaced.Id == moved.Id {
t.Errorf("policy %s was kept across a parameter change, want it replaced", moved.Id)
}
f.UpdateService(svc, func(s *corev1.Service) {
delete(s.Annotations, annotationStickinessMethod)
delete(s.Annotations, annotationStickinessParams)
})
f.WaitForNoStickinessPolicy(rules["8080"])
if after := ruleIDsByPort(f.WaitForLBRules(lbName, 2)); !maps.Equal(after, rules) {
t.Errorf("rules changed %v -> %v, want them kept", rules, after)
}
}
// A policy added by hand stays on a Service that does not ask for stickiness.
func TestAnnot_StickinessForeignPolicy(t *testing.T) {
f := NewFramework(t)
svc := f.CreateLBService(nil)
f.WaitForIngressIP(svc)
rules := f.WaitForLBRules(defaultLoadBalancerName(svc), 1)
manual := f.CreateStickinessPolicy(rules[0].Id, "manual", "SourceBased", map[string]string{"tablesize": "200k"})
f.ResyncAndWait(svc)
live, err := f.StickinessPolicies(rules[0].Id)
if err != nil {
t.Fatalf("listing stickiness policies: %v", err)
}
if len(live) != 1 || live[0].Id != manual {
t.Errorf("live policies = %+v, want the hand-made %s kept", live, manual)
}
}
// A stickiness change that cannot be applied fails the sync and leaves the current policies alone,
// whether the controller or CloudStack rejects it. Each case uses its own Service, because failing
// syncs keep recording events and Kubernetes drops events for an object that records too many.
func TestAnnot_StickinessRejected(t *testing.T) {
cases := []struct {
name string
method string
params string
undeclared bool
wantErr string
}{
{name: "a value CloudStack rejects", method: "SourceBased", params: "tablesize=abc", wantErr: "tablesize"},
{name: "a method the network does not offer", method: "NoSuchMethod", wantErr: "not supported on this network"},
{name: "an LbCookie mode HAProxy does not know", method: "LbCookie", params: "mode=bogus", wantErr: "insert, rewrite, prefix"},
{name: "an empty parameter value", method: "LbCookie", params: "cookie-name=", wantErr: "missing value"},
{name: "a character HAProxy would misread", method: "LbCookie", params: "cookie-name=a#b", wantErr: "are not allowed"},
{name: "LbCookie on a port without appProtocol http", method: "LbCookie", undeclared: true, wantErr: "appProtocol: http"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := NewFramework(t)
http := "http"
svc := f.CreateLBService(func(s *corev1.Service) {
s.Annotations = map[string]string{
annotationStickinessMethod: "SourceBased",
annotationStickinessParams: "tablesize=200k",
}
if !tc.undeclared {
s.Spec.Ports[0].AppProtocol = &http
}
})
policies := map[string]string{}
for _, rule := range f.WaitForLBRules(defaultLoadBalancerName(svc), 1) {
policies[rule.Id] = f.WaitForStickinessPolicy(rule.Id, "SourceBased", map[string]string{"tablesize": "200k"}).Id
}
f.UpdateService(svc, func(s *corev1.Service) {
s.Annotations[annotationStickinessMethod] = tc.method
s.Annotations[annotationStickinessParams] = tc.params
})
f.WaitForSyncFailure(svc, tc.wantErr)
for ruleID, policyID := range policies {
live, err := f.StickinessPolicies(ruleID)
if err != nil {
t.Fatalf("listing stickiness policies of rule %s: %v", ruleID, err)
}
if len(live) != 1 || live[0].Id != policyID {
t.Errorf("rule %s policies = %+v, want %s untouched", ruleID, live, policyID)
}
}
})
}
}
// A new Service whose policy CloudStack rejects keeps its IP and rule and stays pending. Once the
// annotation is fixed, it publishes the same IP.
func TestAnnot_StickinessNewServiceRejected(t *testing.T) {
f := NewFramework(t)
svc := f.CreateLBService(func(s *corev1.Service) {
s.Annotations = map[string]string{
annotationStickinessMethod: "SourceBased",
annotationStickinessParams: "tablesize=abc",
}
})
lbName := defaultLoadBalancerName(svc)
f.WaitForSyncFailure(svc, "tablesize")
rules := f.WaitForLBRules(lbName, 1)
current, err := f.K8s.CoreV1().Services(svc.Namespace).Get(context.Background(), svc.Name, metav1.GetOptions{})
if err != nil {
t.Fatalf("getting service: %v", err)
}
if len(current.Status.LoadBalancer.Ingress) != 0 {
t.Errorf("ingress = %v, want none while the sync fails", current.Status.LoadBalancer.Ingress)
}
f.UpdateService(svc, func(s *corev1.Service) {
s.Annotations[annotationStickinessParams] = "tablesize=200k"
})
if ingress := f.WaitForIngressIP(svc); ingress.IP != rules[0].Publicip {
t.Errorf("ingress IP = %q, want the IP the failed sync kept, %q", ingress.IP, rules[0].Publicip)
}
f.WaitForStickinessPolicy(rules[0].Id, "SourceBased", map[string]string{"tablesize": "200k"})
}
func TestAnnot_ExplicitLoadBalancerIP(t *testing.T) {
f := NewFramework(t)
freeIP, err := f.FreePublicIP()
if err != nil {
t.Fatalf("finding a free public IP: %v", err)
}
svc := f.CreateLBService(func(s *corev1.Service) {
s.Spec.LoadBalancerIP = freeIP
})
ingress := f.WaitForIngressIP(svc)
if ingress.IP != freeIP {
t.Fatalf("ingress IP = %q, want requested %q", ingress.IP, freeIP)
}
// The annotation is what routes deletion through the disassociation path.
f.Eventually(lbSyncTimeout, lbSyncInterval, "ip-associated-by-controller annotation",
func() (bool, error) {
current, err := f.K8s.CoreV1().Services(svc.Namespace).Get(
context.Background(), svc.Name, metav1.GetOptions{})
if err != nil {
return false, err
}
return current.Annotations[annotationIPAssociated] == "true", nil
})
f.DeleteServiceAndWait(svc)
f.Eventually(lbSyncTimeout, lbSyncInterval, "explicitly requested IP to be released",
func() (bool, error) {
ip, err := f.PublicIPByAddress(freeIP)
if err != nil || ip == nil {
return false, err
}
return ip.Allocated == "", nil
})
}
// TestAnnot_ProxyProtocolToggle is the end-to-end regression test for issue #2:
// toggling the proxy protocol annotation on a live service used to wedge
// reconciliation for good. The rule name embeds the protocol and rules were
// looked up by name, so the changed protocol missed the lookup and the
// controller tried to create a second rule on a public port the old rule still
// held, which CloudStack rejects as a port conflict.
func TestAnnot_ProxyProtocolToggle(t *testing.T) {
f := NewFramework(t)
svc := f.CreateLBService(nil)
lbName := defaultLoadBalancerName(svc)
f.WaitForIngressIP(svc)
rules := f.WaitForLBRules(lbName, 1)
originalRuleID := rules[0].Id
if rules[0].Protocol != "tcp" {
t.Fatalf("rule protocol = %q, want tcp before the toggle", rules[0].Protocol)
}
// settle waits for exactly one rule carrying the wanted protocol and name,
// and returns its ID so the caller can tell an update from a recreate.
settle := func(protocol string) string {
t.Helper()
wantName := fmt.Sprintf("%s-%s-80", lbName, protocol)
var ruleID string
f.Eventually(lbSyncTimeout, lbSyncInterval, "the rule to settle on "+protocol,
func() (bool, error) {
current, err := f.LBRules(lbName)
if err != nil {
return false, err
}
if len(current) != 1 {
return false, fmt.Errorf("saw %d rules, want 1", len(current))
}
if current[0].Protocol != protocol {
return false, fmt.Errorf("protocol is %q, want %q", current[0].Protocol, protocol)
}
if current[0].Name != wantName {
return false, fmt.Errorf("name is %q, want %q", current[0].Name, wantName)
}
ruleID = current[0].Id
return true, nil
})
return ruleID
}
f.UpdateService(svc, func(s *corev1.Service) {
s.Annotations = map[string]string{annotationProxyProtocol: "true"}
})
proxyRuleID := settle("tcp-proxy")
if proxyRuleID != originalRuleID {
t.Errorf("enabling the proxy protocol recreated the rule (%s -> %s), want an in-place update",
originalRuleID, proxyRuleID)
}
f.UpdateService(svc, func(s *corev1.Service) {
delete(s.Annotations, annotationProxyProtocol)
})
revertedRuleID := settle("tcp")
if revertedRuleID != proxyRuleID {
t.Errorf("disabling the proxy protocol recreated the rule (%s -> %s), want an in-place update",
proxyRuleID, revertedRuleID)
}
// The public port stayed open throughout: the firewall rule is keyed on the
// IP protocol, which both tcp and tcp-proxy map to.
fwRules, err := f.FirewallRules(rules[0].Publicipid)
if err != nil {
t.Fatalf("listing firewall rules: %v", err)
}
found := false
for _, fw := range fwRules {
if fw.Startport == 80 && fw.Endport == 80 && strings.EqualFold(fw.Protocol, "tcp") {
found = true
}
}
if !found {
t.Errorf("no tcp firewall rule for port 80 after the toggle; got %+v", fwRules)
}
}