This is an automated email from the ASF dual-hosted git repository.
DaanHoogland pushed a commit to branch main
in repository
https://gitbox.apache.org/repos/asf/cloudstack-kubernetes-provider.git
The following commit(s) were added to refs/heads/main by this push:
new 0bd7887e Feat: Add support for stickyness policy (#88)
0bd7887e is described below
commit 0bd7887e8df3cda7285da32ad95344e2562ef0c8
Author: Vishesh <[email protected]>
AuthorDate: Wed Oct 7 13:29:14 2026 +0530
Feat: Add support for stickyness policy (#88)
---
README.md | 143 +++++++
cloudstack_loadbalancer.go | 492 ++++++++++++++++++++++-
cloudstack_loadbalancer_test.go | 858 +++++++++++++++++++++++++++++++++++++++-
docs/development.md | 14 +-
go.mod | 4 +-
go.sum | 4 +-
hack/e2e/40-ccm-deploy.sh | 29 +-
test/e2e/annotations_test.go | 171 +++++++-
test/e2e/framework.go | 165 ++++++++
test/e2e/loadbalancer_test.go | 48 +++
10 files changed, 1879 insertions(+), 49 deletions(-)
diff --git a/README.md b/README.md
index cf936d2a..8314a37f 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,11 @@ for example when the API user is not allowed to call
`listCapabilities`.
The access token needs to be able to fetch VM information and deploy load
balancers in the project or domain where the nodes reside.
+Stickiness policies also need `listLBStickinessPolicies`,
`createLBStickinessPolicy` and
+`deleteLBStickinessPolicy`. The account that CloudStack's Kubernetes service
creates for clusters in
+projects cannot call them, so stickiness does not work there yet. Services
without stickiness are
+not affected.
+
To create the secret, use the following command:
```bash
kubectl -n kube-system create secret generic cloudstack-secret
--from-file=cloud-config
@@ -180,6 +185,119 @@ list, which briefly interrupts traffic on that port.
Setting it to an empty value (`""`) sends an empty CIDR list to CloudStack —
it does not block all
traffic.
+####
`service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name`
+
+**Type:** String
+
+**Default:** Not set (no stickiness policy)
+
+**Description:** Adds a CloudStack **LB stickiness policy** to the service's
load balancer rules,
+so the load balancer sends a client to the same node each time. The
VirtualRouter, which VPC
+routers also use, supports:
+
+| Method | Keeps a client by | Ports |
+| --- | --- | --- |
+| `LbCookie` | A cookie the load balancer adds | Ports with `appProtocol:
http` |
+| `SourceBased` | The client's IP address | All TCP ports |
+
+`LbCookie` switches its ports to HTTP mode, which breaks TLS and any other
traffic that is not
+plain HTTP. So it only goes on ports that set
+[`appProtocol:
http`](https://kubernetes.io/docs/concepts/services-networking/service/#application-protocol),
+and a service with no such port fails to sync. The ingress-nginx Helm chart
sets `appProtocol` on
+its ports by default, so its HTTP port gets the cookie and its HTTPS port does
not.
+
+`LbCookie` cannot work on HTTPS ports, because the load balancer passes TLS
through and never sees
+the HTTP headers. Use `SourceBased` there, or the cookie affinity of the
ingress controller or
+Gateway that terminates TLS.
+
+`AppCookie` is not supported. CloudStack sets it up with HAProxy's
`appsession` option, which
+HAProxy removed in version 1.6.
+
+Method and parameter names are checked against what the network supports
before anything is
+created, so a typo fails the sync without changing CloudStack. Method names
are not case sensitive.
+
+Stickiness keeps a client on the same **node**, not the same pod. kube-proxy
on that node can still
+send the request to a pod on another node. Set `externalTrafficPolicy: Local`
to keep requests on
+the node's own pods. The router health-checks each node, so nodes without a
local pod get no
+traffic.
+
+**Use Case:** Applications that keep a client's state in memory, such as
sessions, and need the
+same client to reach the same node.
+
+**Example:**
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: my-ingress
+ annotations:
+
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name:
"LbCookie"
+
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param:
"cookie-name=SERVERID,nocache=true"
+spec:
+ type: LoadBalancer
+ externalTrafficPolicy: Local
+ ports:
+ - name: http
+ port: 80
+ appProtocol: http
+ - name: https
+ port: 443
+ appProtocol: https
+```
+
+**Notes:**
+- Changing the method or parameters replaces the policy. With `SourceBased`,
clients may move to
+ another node. With `LbCookie`, they keep their node unless the cookie name
changes.
+- If CloudStack rejects a change, the current policy stays. If the router
fails to apply it, the
+ rule can be left without a policy until the service is synced again.
+- Removing the annotation deletes the policies the controller created. They
have the description
+ `Managed by the CloudStack Kubernetes Provider`. Policies added by hand are
kept, except on ports
+ the annotations select, where the controller replaces them, and on rules the
controller
+ recreates, for example after a node port change.
+- A new service whose policy CloudStack rejects keeps its public IP and rules,
but stays
+ `<pending>` until the annotation is fixed.
+- Changes apply the next time the service is synced, which happens when the
service changes or the
+ controller restarts.
+
+####
`service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param`
+
+**Type:** String (comma-separated `key=value` list)
+
+**Default:** Not set (no parameters)
+
+**Description:** Parameters for the method in
+`service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name`.
Without a method,
+this annotation does nothing. The VirtualRouter supports:
+
+| Method | Parameter | Value |
+| --- | --- | --- |
+| `LbCookie` | `cookie-name` | Name of the cookie. CloudStack picks one if it
is not set |
+| `LbCookie` | `mode` | `insert` (default), `rewrite` or `prefix` |
+| `LbCookie` | `domain` | Domain the cookie is set for |
+| `LbCookie` | `nocache`, `indirect`, `postonly` | Flags: `true` turns one on,
`false` leaves it off |
+| `SourceBased` | `tablesize` | Size of the client table: a number with `k`,
`m` or `g`, such as `200k` |
+| `SourceBased` | `expire` | How long an idle client is remembered: a number
with `d`, `h`, `m` or `s`, such as `30m` |
+
+Parameter names are not case sensitive. Flags accept the values of Go's
`strconv.ParseBool`, such
+as `true`, `false`, `1` and `0`.
+
+**Format:** Comma-separated `key=value` pairs. Spaces around entries and empty
entries are ignored.
+CloudStack copies the values into the router's HAProxy config, so the sync
fails on:
+
+- an entry without a value, such as `nocache=` (write `nocache=true` instead)
+- a space or one of `= & " ' \ #` inside a key or value
+- a key that is set twice
+- an unknown parameter, an `LbCookie` mode other than the three above, or a
flag that is not
+ `true` or `false`
+
+CloudStack itself rejects `tablesize` and `expire` values without a valid
suffix.
+
+**Example:**
+```yaml
+
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name:
"SourceBased"
+
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param:
"tablesize=200k,expire=30m"
+```
+
####
`service.beta.kubernetes.io/cloudstack-load-balancer-ip-associated-by-controller`
**Type:** Boolean (`"true"` or `"false"`)
@@ -269,6 +387,13 @@ annotation for it.
Any other value makes the service fail to sync with `unsupported load balancer
affinity`. Other
CloudStack algorithms, such as `leastconn`, cannot currently be selected.
+The algorithm is separate from stickiness. `spec.sessionAffinity: ClientIP`
picks the algorithm,
+while the
+[`stickiness-method-name`](#servicebetakubernetesiocloudstack-load-balancer-stickiness-method-name)
+and
+[`stickiness-method-param`](#servicebetakubernetesiocloudstack-load-balancer-stickiness-method-param)
+annotations add a stickiness policy. The two can be used together.
+
### VPC Networks
VPC networks are supported. VPC networks normally do not offer the Firewall
service, so the
@@ -385,6 +510,19 @@ autoscaler has marked it for deletion.
The image architecture does not match the node. Releases up to v1.1.0 were
published for amd64 only;
use a newer release, which ships multi-architecture images including arm64.
+### A TLS or TCP port stopped working after enabling `LbCookie` stickiness
+
+`LbCookie` switches its ports to HTTP mode, which breaks traffic that is not
plain HTTP. It only
+goes on ports with `appProtocol: http`. Set that only on plain HTTP ports, or
use `SourceBased`,
+which works on any TCP port.
+
+### A service's events show a stickiness error
+
+The controller or CloudStack rejected the stickiness annotations, and the
event says why.
+`Failed to create stickiness policy` with no reason means the router could not
apply the policy.
+If CloudStack rejects a change, an existing service keeps its current policy.
A new service keeps
+its public IP and rules, but stays `<pending>` until the annotation is fixed.
+
## Migration Guide
There are several notable differences to the old Kubernetes CloudStack cloud
provider that need to be taken into
@@ -405,6 +543,11 @@ A rule created by the old provider carries no protocol in
its name. The controll
rule on the first reconcile, matching it by public IP, IP protocol and public
port, and renames it
to the current scheme. Existing rules therefore do not have to be removed
before migrating.
+The old provider did not manage stickiness, so its rules may have policies
added by hand. The
+controller keeps them. It only deletes policies it created, which have the
description
+`Managed by the CloudStack Kubernetes Provider`. On ports the stickiness
annotations select, the
+controller's policy replaces a hand-made one.
+
:warning: **Rules of a Service that is recreated during the migration are not
adopted.**
Rule names derive from the Service UID, so a Service recreated with a new UID
leaves its old rules
diff --git a/cloudstack_loadbalancer.go b/cloudstack_loadbalancer.go
index 481c114b..753322dc 100644
--- a/cloudstack_loadbalancer.go
+++ b/cloudstack_loadbalancer.go
@@ -21,11 +21,16 @@ package cloudstack
import (
"context"
+ "encoding/json"
+ "errors"
"fmt"
+ "maps"
"net"
+ "slices"
"sort"
"strconv"
"strings"
+ "unicode"
"github.com/apache/cloudstack-go/v2/cloudstack"
"github.com/blang/semver/v4"
@@ -57,6 +62,14 @@ const (
// associated the IP address. This annotation is set by the controller
when it associates
// an unallocated IP, and is used to determine if the IP should be
disassociated on deletion.
ServiceAnnotationLoadBalancerIPAssociatedByController =
"service.beta.kubernetes.io/cloudstack-load-balancer-ip-associated-by-controller"
//nolint:gosec
+
+ // ServiceAnnotationLoadBalancerStickinessMethodName sets the
stickiness method, LbCookie or
+ // SourceBased, for the Service's load balancer rules.
+ ServiceAnnotationLoadBalancerStickinessMethodName =
"service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name"
+
+ // ServiceAnnotationLoadBalancerStickinessMethodParam sets the method's
parameters as a
+ // comma-separated list of key=value pairs.
+ ServiceAnnotationLoadBalancerStickinessMethodParam =
"service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param"
)
// cidrListUpdateVersion is the first CloudStack release whose
updateLoadBalancerRule API
@@ -137,6 +150,11 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context,
clusterName string, s
return nil, fmt.Errorf("requested load balancer with no ports")
}
+ stickiness, err := parseStickinessSpec(service)
+ if err != nil {
+ return nil, err
+ }
+
// Get the load balancer details and existing rules.
lb, err := cs.getLoadBalancer(service)
if err != nil {
@@ -163,6 +181,17 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context,
clusterName string, s
return nil, err
}
+ // Read the network before an IP is associated, so a stickiness method
the network does not
+ // support fails before anything is created.
+ network, _, err := lb.Network.GetNetworkByID(lb.networkID,
cloudstack.WithProject(lb.projectID))
+ if err != nil {
+ return nil, err
+ }
+ if err := stickiness.normalise(supportedStickinessMethods(network));
err != nil {
+ return nil, err
+ }
+
+ rulesApplied := false
if !lb.hasLoadBalancerIP() {
// Create or retrieve the load balancer IP.
if err := lb.getLoadBalancerIP(service.Spec.LoadBalancerIP);
err != nil {
@@ -170,8 +199,9 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context,
clusterName string, s
}
if lb.ipAddr != "" && lb.ipAddr != service.Spec.LoadBalancerIP {
+ // Once the rules exist on the new IP, keep it, so the
next sync can retry against them.
defer func(lb *loadBalancer) {
- if err != nil {
+ if err != nil && !rulesApplied {
if err := lb.releaseLoadBalancerIP();
err != nil {
klog.Errorf(err.Error())
}
@@ -196,11 +226,6 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context,
clusterName string, s
return nil, err
}
- network, _, err := lb.Network.GetNetworkByID(lb.networkID,
cloudstack.WithProject(lb.projectID))
- if err != nil {
- return nil, err
- }
-
blocking, rest := lb.partitionObsoleteRules(desired)
// Obsolete rules holding a public port that a new rule needs have to
go first, or
@@ -209,13 +234,18 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx
context.Context, clusterName string, s
return nil, err
}
- if err := lb.applyLoadBalancerRules(desired, service, network,
cs.version); err != nil {
+ rules, err := lb.applyLoadBalancerRules(desired, service, network,
cs.version)
+ if err != nil {
return nil, err
}
+ rulesApplied = true
// Everything else is removed only once the desired rules are in place,
so a failure here
- // can never leave the service without the rules it does need.
- if err := lb.pruneRules(rest, desired, network); err != nil {
+ // can never leave the service without the rules it does need.
Stickiness is applied last,
+ // even if the prune fails.
+ pruneErr := lb.pruneRules(rest, desired, network)
+ stickinessErr := lb.applyStickinessPolicies(desired, rules, stickiness)
+ if err := errors.Join(pruneErr, stickinessErr); err != nil {
return nil, err
}
@@ -275,7 +305,7 @@ func (cs *CSCloud) UpdateLoadBalancer(ctx context.Context,
clusterName string, s
}
if len(remove) > 0 {
- klog.V(4).Infof("Removing old hosts (%v) from load
balancer rule: %v", assign, lbRule.Name)
+ klog.V(4).Infof("Removing old hosts (%v) from load
balancer rule: %v", remove, lbRule.Name)
if err := lb.removeHostsFromRule(lbRule, remove); err
!= nil {
return err
}
@@ -1008,15 +1038,20 @@ func (lb *loadBalancer) pruneRules(obsolete
[]obsoleteRule, desired []desiredLBR
// ensureLoadBalancerRule brings the load balancer rule of one desired service
port in line and
// returns it: an up-to-date rule is left alone, an outdated one is updated in
place, and a
// missing one is created. A rule that cannot be updated is deleted
immediately before its
-// replacement is created, which keeps the port unserved for as short a time
as possible.
+// replacement is created, which keeps the port unserved for as short a time
as possible. An
+// adopted rule that has no hosts gets them assigned.
func (lb *loadBalancer) ensureLoadBalancerRule(d desiredLBRule, service
*corev1.Service, version semver.Version) (*cloudstack.LoadBalancerRule, error) {
switch d.change {
case ruleUpToDate:
klog.V(4).Infof("Load balancer rule %v is up-to-date", d.name)
- return d.existing, nil
+ return d.existing, lb.repairEmptyRule(d.existing)
case ruleNeedsUpdate:
klog.V(4).Infof("Updating load balancer rule: %v", d.name)
- return d.existing, lb.updateLoadBalancerRule(d.existing,
d.name, d.protocol, service, version)
+ if err := lb.updateLoadBalancerRule(d.existing, d.name,
d.protocol, service, version); err != nil {
+ return nil, err
+ }
+
+ return d.existing, lb.repairEmptyRule(d.existing)
case ruleNeedsRecreate:
klog.V(4).Infof("Deleting load balancer rule %v so it can be
created again", d.existing.Name)
if err := lb.deleteLoadBalancerRule(d.existing); err != nil {
@@ -1038,31 +1073,452 @@ func (lb *loadBalancer) ensureLoadBalancerRule(d
desiredLBRule, service *corev1.
return lbRule, nil
}
+// repairEmptyRule assigns the hosts to an adopted rule that has none, which
happens when a create
+// fails before assigning them. Rules that have hosts are left to
UpdateLoadBalancer, because node
+// sync runs at the same time as this sync and may have newer node data.
+func (lb *loadBalancer) repairEmptyRule(lbRule *cloudstack.LoadBalancerRule)
error {
+ p := lb.LoadBalancer.NewListLoadBalancerRuleInstancesParams(lbRule.Id)
+ instances, err := lb.LoadBalancer.ListLoadBalancerRuleInstances(p)
+ if err != nil {
+ return fmt.Errorf("error retrieving associated instances: %v",
err)
+ }
+ if instances.Count > 0 {
+ return nil
+ }
+
+ klog.V(4).Infof("Assigning hosts (%v) to load balancer rule %v, which
has none", lb.hostIDs, lbRule.Name)
+ return lb.assignHostsToRule(lbRule, lb.hostIDs)
+}
+
// applyLoadBalancerRules creates or updates the load balancer rule of every
desired service
-// port and reconciles the firewall or network ACL rules it needs.
-func (lb *loadBalancer) applyLoadBalancerRules(desired []desiredLBRule,
service *corev1.Service, network *cloudstack.Network, version semver.Version)
error {
+// port and reconciles the firewall or network ACL rules it needs. It returns
the rule of each
+// desired port, in the same order.
+func (lb *loadBalancer) applyLoadBalancerRules(desired []desiredLBRule,
service *corev1.Service, network *cloudstack.Network, version semver.Version)
([]*cloudstack.LoadBalancerRule, error) {
+ rules := make([]*cloudstack.LoadBalancerRule, 0, len(desired))
for _, d := range desired {
lbRule, err := lb.ensureLoadBalancerRule(d, service, version)
if err != nil {
- return err
+ return nil, err
}
+ rules = append(rules, lbRule)
if isFirewallSupported(network.Service) {
klog.V(4).Infof("Creating firewall rules for load
balancer rule: %v (%v:%v:%v)", d.name, d.protocol, lbRule.Publicip, d.port.Port)
if _, err := lb.updateFirewallRule(lbRule.Publicipid,
int(d.port.Port), d.protocol, service.Spec.LoadBalancerSourceRanges); err !=
nil {
- return err
+ return nil, err
}
} else if isNetworkACLSupported(network.Service) {
klog.V(4).Infof("Creating ACL rules for load balancer
rule: %v (%v:%v:%v)", d.name, d.protocol, lbRule.Publicip, d.port.Port)
if _, err := lb.updateNetworkACL(int(d.port.Port),
d.protocol, network.Id); err != nil {
- return err
+ return nil, err
+ }
+ }
+ }
+
+ return rules, nil
+}
+
+const (
+ // stickinessPolicyDescription marks the policies the controller
creates. Only policies with
+ // this description are deleted, so policies added by hand are kept.
+ stickinessPolicyDescription = "Managed by the CloudStack Kubernetes
Provider"
+
+ lbCookieMethod = "LbCookie"
+ appCookieMethod = "AppCookie"
+
+ // stickinessParamForbidden lists the characters a parameter may not
contain. CloudStack splits
+ // stored parameters on "=" and "&", and copies them unquoted into the
HAProxy config, where
+ // quotes, backslashes and "#" have a special meaning.
+ stickinessParamForbidden = "=&\"'\\#"
+)
+
+// lbCookieModes are the cookie modes HAProxy accepts. CloudStack does not
check the mode.
+var lbCookieModes = []string{"insert", "rewrite", "prefix"}
+
+// stickinessSpec is the stickiness policy a Service asks for. ports holds the
selected TCP ports.
+type stickinessSpec struct {
+ method string
+ params map[string]string
+ ports map[int32]bool
+}
+
+// stickinessMethod is a method in a network's SupportedStickinessMethods
capability.
+type stickinessMethod struct {
+ Name string `json:"methodname"`
+ Params []stickinessMethodParam `json:"paramlist"`
+}
+
+// stickinessMethodParam is a parameter of a stickiness method.
+type stickinessMethodParam struct {
+ Name string `json:"paramname"`
+ Required bool `json:"required"`
+ IsFlag bool `json:"isflag"`
+}
+
+// parseStickinessSpec reads the stickiness annotations. It returns nil when
the Service asks for
+// no stickiness. It makes no CloudStack calls, so bad annotations fail before
anything changes.
+func parseStickinessSpec(service *corev1.Service) (*stickinessSpec, error) {
+ method := strings.TrimSpace(getStringFromServiceAnnotation(service,
ServiceAnnotationLoadBalancerStickinessMethodName, ""))
+ if method == "" {
+ return nil, nil
+ }
+ if strings.EqualFold(method, appCookieMethod) {
+ return nil, fmt.Errorf("unsupported stickiness method %s in
annotation %s: HAProxy removed the appsession option it needs", method,
ServiceAnnotationLoadBalancerStickinessMethodName)
+ }
+
+ params, err :=
parseStickinessParams(getStringFromServiceAnnotation(service,
ServiceAnnotationLoadBalancerStickinessMethodParam, ""))
+ if err != nil {
+ return nil, err
+ }
+ ports, err := selectStickinessPorts(service, method)
+ if err != nil {
+ return nil, err
+ }
+
+ return &stickinessSpec{method: method, params: params, ports: ports},
nil
+}
+
+// parseStickinessParams parses the key=value list of the method-param
annotation. Empty values,
+// spaces and the characters in stickinessParamForbidden are rejected, because
CloudStack would
+// read them back differently or write them into a broken HAProxy config.
+func parseStickinessParams(value string) (map[string]string, error) {
+ params := make(map[string]string)
+ seen := make(map[string]bool)
+ for _, entry := range strings.Split(value, ",") {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
+ key, val, found := strings.Cut(entry, "=")
+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)
+ switch {
+ case !found || key == "":
+ return nil, stickinessParamError(entry, "expected
key=value")
+ case val == "":
+ return nil, stickinessParamError(entry,
fmt.Sprintf("missing value, write flags as %s=true", key))
+ case !safeStickinessText(key) || !safeStickinessText(val):
+ return nil, stickinessParamError(entry, `spaces and the
characters = & " ' \ # are not allowed`)
+ }
+
+ lowered := strings.ToLower(key)
+ if seen[lowered] {
+ return nil, stickinessParamError(entry, key+" is set
twice")
+ }
+ seen[lowered] = true
+ params[key] = val
+ }
+
+ return params, nil
+}
+
+// stickinessParamError reports an invalid entry of the method-param
annotation.
+func stickinessParamError(entry, reason string) error {
+ return fmt.Errorf("invalid stickiness parameter %q in annotation %s:
%s", entry, ServiceAnnotationLoadBalancerStickinessMethodParam, reason)
+}
+
+// safeStickinessText reports whether s has no spaces and no forbidden
characters.
+func safeStickinessText(s string) bool {
+ return !strings.ContainsAny(s, stickinessParamForbidden) &&
strings.IndexFunc(s, unicode.IsSpace) < 0
+}
+
+// selectStickinessPorts returns the TCP ports that get the policy. LbCookie
switches its ports to
+// HTTP mode, which breaks TLS and other non-HTTP traffic, so it only goes on
ports with
+// appProtocol: http. Other methods go on every TCP port.
+func selectStickinessPorts(service *corev1.Service, method string)
(map[int32]bool, error) {
+ tcpPorts := make(map[int32]bool)
+ httpPorts := make(map[int32]bool)
+ for _, port := range service.Spec.Ports {
+ if port.Protocol != corev1.ProtocolTCP {
+ continue
+ }
+ tcpPorts[port.Port] = true
+ if port.AppProtocol != nil &&
strings.EqualFold(*port.AppProtocol, "http") {
+ httpPorts[port.Port] = true
+ }
+ }
+
+ if !strings.EqualFold(method, lbCookieMethod) {
+ return tcpPorts, nil
+ }
+ if len(httpPorts) == 0 {
+ return nil, fmt.Errorf("stickiness method %s only works on
plain HTTP ports: set appProtocol: http on them", method)
+ }
+
+ return httpPorts, nil
+}
+
+// wants reports whether a Service port should get the policy.
+func (s *stickinessSpec) wants(port corev1.ServicePort) bool {
+ return s != nil && port.Protocol == corev1.ProtocolTCP &&
s.ports[port.Port]
+}
+
+// matches reports whether a policy is the controller's and matches the spec.
+func (s *stickinessSpec) matches(policy
cloudstack.LBStickinessPolicyStickinesspolicy) bool {
+ return policy.Description == stickinessPolicyDescription &&
+ strings.EqualFold(policy.Methodname, s.method) &&
+ maps.Equal(policy.Params, s.params)
+}
+
+// supportedStickinessMethods returns the stickiness methods the network's
load balancer
+// supports, or nil if the network does not list them.
+func supportedStickinessMethods(network *cloudstack.Network)
[]stickinessMethod {
+ for _, svc := range network.Service {
+ if svc.Name != "Lb" {
+ continue
+ }
+ for _, capability := range svc.Capability {
+ if capability.Name != "SupportedStickinessMethods" {
+ continue
}
+ var methods []stickinessMethod
+ if err := json.Unmarshal([]byte(capability.Value),
&methods); err != nil {
+ klog.Warningf("Cannot read the stickiness
methods of network %v: %v", network.Id, err)
+ return nil
+ }
+ return methods
}
}
return nil
}
+// normalise checks the spec against the methods the network supports, as
CloudStack does, and
+// also checks the LbCookie mode, which CloudStack does not. It writes names
and flags the way
+// CloudStack stores them, so a policy read back compares equal. It does
nothing if the network
+// does not list its methods.
+func (s *stickinessSpec) normalise(methods []stickinessMethod) error {
+ if s == nil || len(methods) == 0 {
+ return nil
+ }
+ method := findStickinessMethod(methods, s.method)
+ if method == nil {
+ return fmt.Errorf("stickiness method %q in annotation %s is not
supported on this network; supported methods: %s", s.method,
ServiceAnnotationLoadBalancerStickinessMethodName,
stickinessMethodNames(methods))
+ }
+
+ params := make(map[string]string, len(s.params))
+ for key, value := range s.params {
+ param := findStickinessParam(method.Params, key)
+ if param == nil {
+ return fmt.Errorf("stickiness parameter %q in
annotation %s is not supported by %s; supported parameters: %s", key,
ServiceAnnotationLoadBalancerStickinessMethodParam, method.Name,
stickinessParamNames(method.Params))
+ }
+ normalised, send, err := normaliseStickinessValue(method.Name,
*param, value)
+ if err != nil {
+ return err
+ }
+ if send {
+ params[param.Name] = normalised
+ }
+ }
+ for _, param := range method.Params {
+ if _, set := params[param.Name]; param.Required && !set {
+ return fmt.Errorf("stickiness method %s needs parameter
%s in annotation %s", method.Name, param.Name,
ServiceAnnotationLoadBalancerStickinessMethodParam)
+ }
+ }
+
+ s.method, s.params = method.Name, params
+ return nil
+}
+
+// normaliseStickinessValue returns the value to send for a parameter, and
whether to send it.
+// HAProxy turns a flag on whenever it is set, whatever the value, so flags
are parsed as booleans
+// and sent as true or left out, like the CloudStack UI does.
+func normaliseStickinessValue(method string, param stickinessMethodParam,
value string) (string, bool, error) {
+ if param.IsFlag {
+ on, err := strconv.ParseBool(value)
+ if err != nil {
+ return "", false, fmt.Errorf("stickiness flag %s in
annotation %s must be true or false, not %q", param.Name,
ServiceAnnotationLoadBalancerStickinessMethodParam, value)
+ }
+ return "true", on, nil
+ }
+ if strings.EqualFold(method, lbCookieMethod) && param.Name == "mode" {
+ mode := strings.ToLower(value)
+ if !slices.Contains(lbCookieModes, mode) {
+ return "", false, fmt.Errorf("stickiness parameter mode
in annotation %s must be one of %s, not %q",
ServiceAnnotationLoadBalancerStickinessMethodParam, strings.Join(lbCookieModes,
", "), value)
+ }
+ return mode, true, nil
+ }
+
+ return value, true, nil
+}
+
+// findStickinessMethod returns the method with the given name, ignoring case
like CloudStack.
+func findStickinessMethod(methods []stickinessMethod, name string)
*stickinessMethod {
+ for i := range methods {
+ if strings.EqualFold(methods[i].Name, name) {
+ return &methods[i]
+ }
+ }
+
+ return nil
+}
+
+// findStickinessParam returns the parameter with the given name, ignoring
case.
+func findStickinessParam(params []stickinessMethodParam, name string)
*stickinessMethodParam {
+ for i := range params {
+ if strings.EqualFold(params[i].Name, name) {
+ return ¶ms[i]
+ }
+ }
+
+ return nil
+}
+
+// stickinessMethodNames lists the supported methods for an error message.
AppCookie is left out
+// because the controller rejects it.
+func stickinessMethodNames(methods []stickinessMethod) string {
+ names := make([]string, 0, len(methods))
+ for _, method := range methods {
+ if !strings.EqualFold(method.Name, appCookieMethod) {
+ names = append(names, method.Name)
+ }
+ }
+
+ return strings.Join(names, ", ")
+}
+
+// stickinessParamNames lists a method's parameters for an error message.
+func stickinessParamNames(params []stickinessMethodParam) string {
+ names := make([]string, 0, len(params))
+ for _, param := range params {
+ names = append(names, param.Name)
+ }
+
+ return strings.Join(names, ", ")
+}
+
+// applyStickinessPolicies updates the stickiness policy of every desired
rule, once the rules are
+// in place. It tries every rule and returns all errors together.
+func (lb *loadBalancer) applyStickinessPolicies(desired []desiredLBRule, rules
[]*cloudstack.LoadBalancerRule, spec *stickinessSpec) error {
+ var errs []error
+ for i, d := range desired {
+ if err := lb.applyStickinessPolicy(d, rules[i], spec); err !=
nil {
+ errs = append(errs, fmt.Errorf("load balancer rule %v:
%w", d.name, err))
+ }
+ }
+
+ return errors.Join(errs...)
+}
+
+// applyStickinessPolicy updates the stickiness policy of one rule. A rule
created in this sync has
+// no policy yet, so it is not looked up. If the account may not call the
stickiness APIs and the
+// rule needs no policy, nothing is done. The account CloudStack's Kubernetes
service creates for
+// clusters in projects is one such account.
+func (lb *loadBalancer) applyStickinessPolicy(d desiredLBRule, lbRule
*cloudstack.LoadBalancerRule, spec *stickinessSpec) error {
+ want := spec.wants(d.port)
+ var live []cloudstack.LBStickinessPolicyStickinesspolicy
+ if !d.createsRule() {
+ var err error
+ live, err = lb.liveStickinessPolicies(lbRule.Id)
+ switch {
+ case err != nil && isNotAllowed(err) && !want:
+ klog.V(4).Infof("Skipping the stickiness policies of
load balancer rule %v: %v", d.name, err)
+ return nil
+ case err != nil && isNotAllowed(err):
+ return fmt.Errorf("%w; stickiness needs the
listLBStickinessPolicies, createLBStickinessPolicy and deleteLBStickinessPolicy
APIs", err)
+ case err != nil:
+ return err
+ }
+ }
+
+ create, stale, err := stickinessChanges(live, spec, want)
+ if err != nil {
+ return err
+ }
+ for _, id := range stale {
+ if err := lb.deleteStickinessPolicy(d.name, id); err != nil {
+ return err
+ }
+ }
+ if create {
+ return lb.createStickinessPolicy(d.name, lbRule.Id, spec)
+ }
+
+ return nil
+}
+
+// isNotAllowed reports whether CloudStack refused a call because the
account's role does not
+// allow that API. CloudStack answers with error 432, which the SDK only keeps
in the message.
+func isNotAllowed(err error) bool {
+ return strings.Contains(err.Error(), "CloudStack API error 432 ")
+}
+
+// stickinessChanges decides what to do with a rule's live policies. A
selected rule should end
+// with one policy, the controller's, matching the spec. Creating it is
enough, as CloudStack then
+// revokes the rule's other policies. CloudStack refuses the create while two
policies are live,
+// and deleting one first could leave the rule without the current policy if
the create then
+// fails, so that case is an error and nothing changes. A rule that is not
selected loses the
+// controller's policies and keeps the rest.
+func stickinessChanges(live []cloudstack.LBStickinessPolicyStickinesspolicy,
spec *stickinessSpec, want bool) (bool, []string, error) {
+ if !want {
+ var owned []string
+ for _, policy := range live {
+ if policy.Description == stickinessPolicyDescription {
+ owned = append(owned, policy.Id)
+ }
+ }
+ return false, owned, nil
+ }
+
+ switch len(live) {
+ case 0:
+ return true, nil, nil
+ case 1:
+ return !spec.matches(live[0]), nil, nil
+ default:
+ return false, nil, fmt.Errorf("the rule has %d stickiness
policies, and CloudStack adds a new one only when there is at most one: remove
the extra ones", len(live))
+ }
+}
+
+// liveStickinessPolicies lists the policies of a rule, skipping revoked ones
CloudStack has not
+// removed yet. Listing by rule ID returns one entry, so no paging is needed,
and the API takes no
+// listall or project ID.
+func (lb *loadBalancer) liveStickinessPolicies(lbRuleID string)
([]cloudstack.LBStickinessPolicyStickinesspolicy, error) {
+ p := lb.LoadBalancer.NewListLBStickinessPoliciesParams()
+ p.SetLbruleid(lbRuleID)
+ l, err := lb.LoadBalancer.ListLBStickinessPolicies(p)
+ if err != nil {
+ return nil, fmt.Errorf("error listing stickiness policies: %w",
err)
+ }
+
+ var live []cloudstack.LBStickinessPolicyStickinesspolicy
+ for _, wrapper := range l.LBStickinessPolicies {
+ for _, policy := range wrapper.Stickinesspolicy {
+ if !strings.EqualFold(policy.State, "Revoked") {
+ live = append(live, policy)
+ }
+ }
+ }
+
+ return live, nil
+}
+
+// createStickinessPolicy creates the spec's policy on a rule, marked as the
controller's.
+// CloudStack revokes the rule's other policies when it applies the new one.
+func (lb *loadBalancer) createStickinessPolicy(lbRuleName string, lbRuleID
string, spec *stickinessSpec) error {
+ klog.V(4).Infof("Creating stickiness policy %v %v on load balancer rule
%v", spec.method, spec.params, lbRuleName)
+ p := lb.LoadBalancer.NewCreateLBStickinessPolicyParams(lbRuleID,
spec.method, lbRuleName)
+ p.SetDescription(stickinessPolicyDescription)
+ p.SetParam(spec.params)
+
+ if _, err := lb.LoadBalancer.CreateLBStickinessPolicy(p); err != nil {
+ return fmt.Errorf("error creating stickiness policy: %v", err)
+ }
+
+ return nil
+}
+
+// deleteStickinessPolicy deletes a stickiness policy.
+func (lb *loadBalancer) deleteStickinessPolicy(lbRuleName string,
stickinessPolicyID string) error {
+ klog.V(4).Infof("Deleting stickiness policy %v from load balancer rule
%v", stickinessPolicyID, lbRuleName)
+ p :=
lb.LoadBalancer.NewDeleteLBStickinessPolicyParams(stickinessPolicyID)
+
+ if _, err := lb.LoadBalancer.DeleteLBStickinessPolicy(p); err != nil {
+ return fmt.Errorf("error deleting stickiness policy %v: %v",
stickinessPolicyID, err)
+ }
+
+ return nil
+}
+
// effectiveSourceCIDRs is the source CIDR list a load balancer rule enforces.
An empty list
// allows every source, since the virtual router's HAProxy configuration adds
no source filter
// for it. Rules created by the in-tree provider, and by this one before it
sent a CIDR list,
diff --git a/cloudstack_loadbalancer_test.go b/cloudstack_loadbalancer_test.go
index 7933fcca..24e8a14a 100644
--- a/cloudstack_loadbalancer_test.go
+++ b/cloudstack_loadbalancer_test.go
@@ -22,6 +22,7 @@ package cloudstack
import (
"context"
"fmt"
+ "maps"
"reflect"
"slices"
"sort"
@@ -4619,8 +4620,14 @@ type ensureLBTestEnv struct {
vm *cloudstack.MockVirtualMachineServiceIface
network *cloudstack.MockNetworkServiceIface
firewall *cloudstack.MockFirewallServiceIface
+ address *cloudstack.MockAddressServiceIface
service *corev1.Service
nodes []*corev1.Node
+
+ instances []*cloudstack.VirtualMachine
+ policies map[string][]cloudstack.LBStickinessPolicyStickinesspolicy
+ policyErr error
+ policyLookups []string
}
func newEnsureLBTestEnv(ctrl *gomock.Controller, annotations
map[string]string, ports []corev1.ServicePort) *ensureLBTestEnv {
@@ -4629,6 +4636,7 @@ func newEnsureLBTestEnv(ctrl *gomock.Controller,
annotations map[string]string,
vm: cloudstack.NewMockVirtualMachineServiceIface(ctrl),
network: cloudstack.NewMockNetworkServiceIface(ctrl),
firewall: cloudstack.NewMockFirewallServiceIface(ctrl),
+ address: cloudstack.NewMockAddressServiceIface(ctrl),
}
e.cs = &CSCloud{
@@ -4637,6 +4645,7 @@ func newEnsureLBTestEnv(ctrl *gomock.Controller,
annotations map[string]string,
VirtualMachine: e.vm,
Network: e.network,
Firewall: e.firewall,
+ Address: e.address,
},
version: semver.Version{Major: 4, Minor: 22, Patch: 0},
}
@@ -4657,9 +4666,40 @@ func newEnsureLBTestEnv(ctrl *gomock.Controller,
annotations map[string]string,
e.nodes = []*corev1.Node{{ObjectMeta: metav1.ObjectMeta{Name:
"node-1"}}}
+ // A sync counts the hosts of each adopted rule and lists its
stickiness policies. By default
+ // the rule already has the node and no policies, so tests that change
neither work as before.
+ e.instances = []*cloudstack.VirtualMachine{{Id: "vm-1"}}
+ e.policies =
map[string][]cloudstack.LBStickinessPolicyStickinesspolicy{}
+
e.lb.EXPECT().NewListLoadBalancerRuleInstancesParams(gomock.Any()).DoAndReturn(func(string)
*cloudstack.ListLoadBalancerRuleInstancesParams {
+ return &cloudstack.ListLoadBalancerRuleInstancesParams{}
+ }).AnyTimes()
+
e.lb.EXPECT().ListLoadBalancerRuleInstances(gomock.Any()).DoAndReturn(e.listRuleInstances).AnyTimes()
+ e.lb.EXPECT().NewListLBStickinessPoliciesParams().DoAndReturn(func()
*cloudstack.ListLBStickinessPoliciesParams {
+ return &cloudstack.ListLBStickinessPoliciesParams{}
+ }).AnyTimes()
+
e.lb.EXPECT().ListLBStickinessPolicies(gomock.Any()).DoAndReturn(e.listStickinessPolicies).AnyTimes()
+
return e
}
+// listRuleInstances answers a rule instance lookup with e.instances.
+func (e *ensureLBTestEnv)
listRuleInstances(*cloudstack.ListLoadBalancerRuleInstancesParams)
(*cloudstack.ListLoadBalancerRuleInstancesResponse, error) {
+ return &cloudstack.ListLoadBalancerRuleInstancesResponse{Count:
len(e.instances), LoadBalancerRuleInstances: e.instances}, nil
+}
+
+// listStickinessPolicies answers a policy lookup from e.policies, one entry
per rule like
+// CloudStack, and records which rule was looked up.
+func (e *ensureLBTestEnv) listStickinessPolicies(p
*cloudstack.ListLBStickinessPoliciesParams)
(*cloudstack.ListLBStickinessPoliciesResponse, error) {
+ ruleID, _ := p.GetLbruleid()
+ e.policyLookups = append(e.policyLookups, ruleID)
+ if e.policyErr != nil {
+ return nil, e.policyErr
+ }
+ wrapper := &cloudstack.LBStickinessPolicy{Lbruleid: ruleID,
Stickinesspolicy: e.policies[ruleID]}
+
+ return &cloudstack.ListLBStickinessPoliciesResponse{Count: 1,
LBStickinessPolicies: []*cloudstack.LBStickinessPolicy{wrapper}}, nil
+}
+
// expectHosts registers the node lookup every run performs before resolving
rules.
func (e *ensureLBTestEnv) expectHosts() {
e.vm.EXPECT().NewListVirtualMachinesParams().Return(&cloudstack.ListVirtualMachinesParams{})
@@ -4673,11 +4713,31 @@ func (e *ensureLBTestEnv) expectHosts() {
// expectHostsAndNetwork registers the host and network lookups every run
performs.
func (e *ensureLBTestEnv) expectHostsAndNetwork() {
- e.expectHosts()
- e.network.EXPECT().GetNetworkByID("net-1",
gomock.Any()).Return(&cloudstack.Network{
+ e.expectHostsAndNetworkWith(&cloudstack.Network{
Id: "net-1",
Service: []cloudstack.NetworkServiceInternal{{Name:
"Firewall"}},
- }, 1, nil)
+ })
+}
+
+// expectHostsAndNetworkWith registers the host lookup and answers the network
lookup with network.
+func (e *ensureLBTestEnv) expectHostsAndNetworkWith(network
*cloudstack.Network) {
+ e.expectHosts()
+ e.network.EXPECT().GetNetworkByID("net-1",
gomock.Any()).Return(network, 1, nil)
+}
+
+// expectRules answers the run's load balancer rule listing with rules.
+func (e *ensureLBTestEnv) expectRules(rules ...*cloudstack.LoadBalancerRule) {
+
e.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
+
e.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
+ Count: len(rules),
+ LoadBalancerRules: rules,
+ }, nil)
+}
+
+// expectFirewallListings answers each of the run's firewall listings, one per
port, with resp.
+func (e *ensureLBTestEnv) expectFirewallListings(resp
*cloudstack.ListFirewallRulesResponse, times int) {
+
e.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}).Times(times)
+ e.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(resp,
nil).Times(times)
}
func TestEnsureLoadBalancer(t *testing.T) {
@@ -4900,7 +4960,7 @@ func TestEnsureLoadBalancer(t *testing.T) {
movedPort80 := corev1.ServicePort{Port: 80, NodePort: 30001,
Protocol: corev1.ProtocolTCP}
sctpPort := corev1.ServicePort{Port: 9000, NodePort: 30900,
Protocol: corev1.ProtocolSCTP}
env := newEnsureLBTestEnv(ctrl, nil,
[]corev1.ServicePort{movedPort80, sctpPort})
- env.expectHosts()
+ env.expectHostsAndNetwork()
// No delete expectation: the existing port 80 rule must
survive.
gomock.InOrder(
@@ -5671,3 +5731,793 @@ func TestPruneRulesScopesNetworkACLsToTheirNetwork(t
*testing.T) {
}
})
}
+
+// virtualRouterStickinessCapability is the SupportedStickinessMethods
capability of a VirtualRouter
+// network, copied from the 4.22.1 simulator. It has not changed since 4.16.
+const virtualRouterStickinessCapability =
`[{"methodname":"LbCookie","paramlist":[{"paramname":"cookie-name","required":false,"isflag":false,"description":"
"},{"paramname":"mode","required":false,"isflag":false,"description":"
"},{"paramname":"nocache","required":false,"isflag":true,"description":"
"},{"paramname":"indirect","required":false,"isflag":true,"description":"
"},{"paramname":"postonly","required":false,"isflag":true,"description":"
"},{"paramname":"domain","required":false," [...]
+
+// virtualRouterNetwork returns a network that lists the VirtualRouter's
stickiness methods.
+func virtualRouterNetwork() *cloudstack.Network {
+ return &cloudstack.Network{
+ Id: "net-1",
+ Service: []cloudstack.NetworkServiceInternal{
+ {Name: "Firewall"},
+ {Name: "Lb", Capability:
[]cloudstack.NetworkServiceInternalCapability{
+ {Name: "SupportedStickinessMethods", Value:
virtualRouterStickinessCapability},
+ }},
+ },
+ }
+}
+
+// stickinessService builds a Service with the given ports and stickiness
annotations. An empty
+// value leaves the annotation out.
+func stickinessService(method, params string, servicePorts
...corev1.ServicePort) *corev1.Service {
+ annotations := map[string]string{}
+ for key, value := range map[string]string{
+ ServiceAnnotationLoadBalancerStickinessMethodName: method,
+ ServiceAnnotationLoadBalancerStickinessMethodParam: params,
+ } {
+ if value != "" {
+ annotations[key] = value
+ }
+ }
+
+ return &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-service", Namespace:
"default", Annotations: annotations},
+ Spec: corev1.ServiceSpec{Ports: servicePorts},
+ }
+}
+
+func TestParseStickinessSpec(t *testing.T) {
+ http := corev1.ServicePort{Name: "http", Port: 80, Protocol:
corev1.ProtocolTCP}
+ https := corev1.ServicePort{Name: "https", Port: 443, Protocol:
corev1.ProtocolTCP}
+ dns := corev1.ServicePort{Name: "dns", Port: 53, Protocol:
corev1.ProtocolUDP}
+ declaredHTTP := withAppProtocol(http, "http")
+ declaredHTTPS := withAppProtocol(https, "https")
+
+ tests := []struct {
+ name string
+ service *corev1.Service
+ wantErr string
+ wantNil bool
+ wantParams map[string]string
+ wantPorts []int32
+ }{
+ {name: "no method means no stickiness", service:
stickinessService("", "cookie-name=X", http), wantNil: true},
+ {name: "LbCookie goes only on ports declaring appProtocol
http", service: stickinessService("LbCookie", "", declaredHTTP, declaredHTTPS,
dns), wantParams: map[string]string{}, wantPorts: []int32{80}},
+ {name: "appProtocol is matched ignoring case", service:
stickinessService("LbCookie", "", withAppProtocol(http, "HTTP"), https),
wantParams: map[string]string{}, wantPorts: []int32{80}},
+ {name: "LbCookie needs appProtocol http even on a single TCP
port", service: stickinessService("LbCookie", "", http, dns), wantErr:
"appProtocol: http"},
+ {name: "LbCookie on several undeclared TCP ports needs
appProtocol", service: stickinessService("LbCookie", "", http, https), wantErr:
"appProtocol: http"},
+ {name: "LbCookie on a port declaring https is rejected",
service: stickinessService("LbCookie", "", declaredHTTPS), wantErr:
"appProtocol: http"},
+ {name: "SourceBased goes on every TCP port whatever appProtocol
says", service: stickinessService("SourceBased", "", declaredHTTP,
declaredHTTPS, dns), wantParams: map[string]string{}, wantPorts: []int32{80,
443}},
+ {name: "AppCookie is rejected", service:
stickinessService("appcookie", "", http), wantErr: "appsession"},
+ {name: "params are trimmed and empty entries dropped", service:
stickinessService("LbCookie", ", cookie-name = SERVERID ,nocache=true,",
declaredHTTP), wantParams: map[string]string{"cookie-name": "SERVERID",
"nocache": "true"}, wantPorts: []int32{80}},
+ {name: "an empty value is rejected with a hint for flags",
service: stickinessService("LbCookie", "nocache=", declaredHTTP), wantErr:
"nocache=true"},
+ {name: "an entry without = is rejected", service:
stickinessService("LbCookie", "nocache", declaredHTTP), wantErr: "expected
key=value"},
+ {name: "= in a value is rejected", service:
stickinessService("LbCookie", "cookie-name=a=b", declaredHTTP), wantErr: "are
not allowed"},
+ {name: "& in a value is rejected", service:
stickinessService("LbCookie", "domain=x&y", declaredHTTP), wantErr: "are not
allowed"},
+ {name: "a quote in a value is rejected", service:
stickinessService("LbCookie", `cookie-name=SERVER"ID`, declaredHTTP), wantErr:
"are not allowed"},
+ {name: "# in a value is rejected", service:
stickinessService("LbCookie", "domain=a#b", declaredHTTP), wantErr: "are not
allowed"},
+ {name: "a backslash in a value is rejected", service:
stickinessService("LbCookie", `cookie-name=a\b`, declaredHTTP), wantErr: "are
not allowed"},
+ {name: "whitespace in a value is rejected", service:
stickinessService("LbCookie", "cookie-name=my cookie", declaredHTTP), wantErr:
"are not allowed"},
+ {name: "whitespace in a key is rejected", service:
stickinessService("LbCookie", "cookie name=x", declaredHTTP), wantErr: "are not
allowed"},
+ {name: "a key set twice is rejected whatever its case",
service: stickinessService("LbCookie", "mode=insert,MODE=rewrite",
declaredHTTP), wantErr: "set twice"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec, err := parseStickinessSpec(tt.service)
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(),
tt.wantErr) || !strings.Contains(err.Error(), "stickiness") {
+ t.Fatalf("error = %v, want it to
mention %q and stickiness", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if tt.wantNil {
+ if spec != nil {
+ t.Fatalf("spec = %+v, want nil", spec)
+ }
+ return
+ }
+ if !reflect.DeepEqual(spec.params, tt.wantParams) {
+ t.Errorf("params = %v, want %v", spec.params,
tt.wantParams)
+ }
+ gotPorts := slices.Sorted(maps.Keys(spec.ports))
+ if !slices.Equal(gotPorts, tt.wantPorts) {
+ t.Errorf("ports = %v, want %v", gotPorts,
tt.wantPorts)
+ }
+ })
+ }
+}
+
+func TestStickinessSpecWants(t *testing.T) {
+ spec := &stickinessSpec{method: "LbCookie", ports: map[int32]bool{80:
true}}
+ tests := []struct {
+ name string
+ spec *stickinessSpec
+ port corev1.ServicePort
+ want bool
+ }{
+ {name: "a selected TCP port", spec: spec, port:
corev1.ServicePort{Port: 80, Protocol: corev1.ProtocolTCP}, want: true},
+ {name: "an unselected TCP port", spec: spec, port:
corev1.ServicePort{Port: 443, Protocol: corev1.ProtocolTCP}},
+ {name: "a UDP port with a selected number", spec: spec, port:
corev1.ServicePort{Port: 80, Protocol: corev1.ProtocolUDP}},
+ {name: "no spec", port: corev1.ServicePort{Port: 80, Protocol:
corev1.ProtocolTCP}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.spec.wants(tt.port); got != tt.want {
+ t.Errorf("wants = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSupportedStickinessMethods(t *testing.T) {
+ if got := supportedStickinessMethods(virtualRouterNetwork()); len(got)
!= 3 || got[0].Name != "LbCookie" || !got[0].Params[2].IsFlag {
+ t.Errorf("methods = %+v, want the VirtualRouter's three with
nocache as a flag", got)
+ }
+ if got := supportedStickinessMethods(&cloudstack.Network{Service:
[]cloudstack.NetworkServiceInternal{{Name: "Firewall"}}}); got != nil {
+ t.Errorf("methods = %+v, want nil for a network without the Lb
service", got)
+ }
+ unreadable := &cloudstack.Network{Service:
[]cloudstack.NetworkServiceInternal{{Name: "Lb", Capability:
[]cloudstack.NetworkServiceInternalCapability{
+ {Name: "SupportedStickinessMethods", Value: "not json"},
+ }}}}
+ if got := supportedStickinessMethods(unreadable); got != nil {
+ t.Errorf("methods = %+v, want nil for an unreadable
capability", got)
+ }
+}
+
+func TestStickinessSpecNormalise(t *testing.T) {
+ methods := supportedStickinessMethods(virtualRouterNetwork())
+ tests := []struct {
+ name string
+ method string
+ params map[string]string
+ methods []stickinessMethod
+ wantMethod string
+ wantParams map[string]string
+ wantErr string
+ }{
+ {name: "method and param names take the advertised spelling",
method: "lbcookie", params: map[string]string{"Cookie-Name": "SERVERID"},
methods: methods, wantMethod: "LbCookie", wantParams:
map[string]string{"cookie-name": "SERVERID"}},
+ {name: "a true flag is sent as true", method: "LbCookie",
params: map[string]string{"nocache": "1"}, methods: methods, wantMethod:
"LbCookie", wantParams: map[string]string{"nocache": "true"}},
+ {name: "a false flag is left out", method: "LbCookie", params:
map[string]string{"nocache": "false", "indirect": "True"}, methods: methods,
wantMethod: "LbCookie", wantParams: map[string]string{"indirect": "true"}},
+ {name: "a flag that is not a boolean is rejected", method:
"LbCookie", params: map[string]string{"nocache": "maybe"}, methods: methods,
wantErr: "true or false"},
+ {name: "the cookie mode is lower-cased", method: "LbCookie",
params: map[string]string{"mode": "INSERT"}, methods: methods, wantMethod:
"LbCookie", wantParams: map[string]string{"mode": "insert"}},
+ {name: "an unknown cookie mode is rejected", method:
"LbCookie", params: map[string]string{"mode": "bogus"}, methods: methods,
wantErr: "insert, rewrite, prefix"},
+ {name: "an unknown method lists the supported ones", method:
"NoSuchMethod", methods: methods, wantErr: "supported methods: LbCookie,
SourceBased"},
+ {name: "an unknown param lists the method's", method:
"SourceBased", params: map[string]string{"cookie-name": "x"}, methods: methods,
wantErr: "tablesize, expire"},
+ {name: "a missing required param is rejected", method:
"Custom", methods: []stickinessMethod{{Name: "Custom", Params:
[]stickinessMethodParam{{Name: "key", Required: true}}}}, wantErr: "needs
parameter key"},
+ {name: "without advertised methods nothing changes", method:
"lbcookie", params: map[string]string{"nocache": "false"}, wantMethod:
"lbcookie", wantParams: map[string]string{"nocache": "false"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec := &stickinessSpec{method: tt.method, params:
tt.params}
+ if spec.params == nil {
+ spec.params = map[string]string{}
+ }
+ err := spec.normalise(tt.methods)
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(),
tt.wantErr) || !strings.Contains(err.Error(), "stickiness") {
+ t.Fatalf("error = %v, want it to
mention %q and stickiness", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if spec.method != tt.wantMethod ||
!maps.Equal(spec.params, tt.wantParams) {
+ t.Errorf("spec = %s %v, want %s %v",
spec.method, spec.params, tt.wantMethod, tt.wantParams)
+ }
+ })
+ }
+
+ var none *stickinessSpec
+ if err := none.normalise(methods); err != nil {
+ t.Errorf("normalising no spec: %v", err)
+ }
+}
+
+func TestStickinessChanges(t *testing.T) {
+ spec := &stickinessSpec{method: "LbCookie", params:
map[string]string{"cookie-name": "SERVERID"}}
+ ours := func(id string, params map[string]string)
cloudstack.LBStickinessPolicyStickinesspolicy {
+ return cloudstack.LBStickinessPolicyStickinesspolicy{Id: id,
Methodname: "LbCookie", Params: params, Description:
stickinessPolicyDescription}
+ }
+ foreign := func(id string)
cloudstack.LBStickinessPolicyStickinesspolicy {
+ return cloudstack.LBStickinessPolicyStickinesspolicy{Id: id,
Methodname: "LbCookie", Params: map[string]string{"cookie-name": "SERVERID"}}
+ }
+ matching := ours("ours", map[string]string{"cookie-name": "SERVERID"})
+
+ tests := []struct {
+ name string
+ live []cloudstack.LBStickinessPolicyStickinesspolicy
+ want bool
+ wantCreate bool
+ wantDeletes []string
+ wantErr string
+ }{
+ {name: "a selected rule without a policy gets one", want: true,
wantCreate: true},
+ {name: "a selected rule with our matching policy is left
alone", live: append([]cloudstack.LBStickinessPolicyStickinesspolicy{},
matching), want: true},
+ {name: "a changed policy of ours is replaced by one create",
live: append([]cloudstack.LBStickinessPolicyStickinesspolicy{}, ours("ours",
map[string]string{"cookie-name": "OLD"})), want: true, wantCreate: true},
+ {name: "an identical hand-made policy on a selected rule is
replaced", live: append([]cloudstack.LBStickinessPolicyStickinesspolicy{},
foreign("manual")), want: true, wantCreate: true},
+ {name: "ours and a hand-made policy are reported and nothing is
deleted", live: append([]cloudstack.LBStickinessPolicyStickinesspolicy{},
matching, foreign("manual")), want: true, wantErr: "remove the extra ones"},
+ {name: "two hand-made policies are reported and nothing is
deleted", live: append([]cloudstack.LBStickinessPolicyStickinesspolicy{},
foreign("a"), foreign("b")), want: true, wantErr: "remove the extra ones"},
+ {name: "an unselected rule loses only our policies", live:
append([]cloudstack.LBStickinessPolicyStickinesspolicy{}, matching,
foreign("manual"))},
+ {name: "an unselected rule without policies is left alone"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ wantSpec := spec
+ if !tt.want {
+ wantSpec = nil
+ }
+ create, deletes, err := stickinessChanges(tt.live,
wantSpec, tt.want)
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(),
tt.wantErr) {
+ t.Fatalf("error = %v, want it to
mention %q", err, tt.wantErr)
+ }
+ if create || len(deletes) > 0 {
+ t.Errorf("create, deletes = %v, %v;
want no changes with the error", create, deletes)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ wantDeletes := tt.wantDeletes
+ if !tt.want && len(tt.live) > 0 {
+ wantDeletes = []string{"ours"}
+ }
+ if create != tt.wantCreate || !slices.Equal(deletes,
wantDeletes) {
+ t.Errorf("create, deletes = %v, %v; want %v,
%v", create, deletes, tt.wantCreate, wantDeletes)
+ }
+ })
+ }
+}
+
+func TestLiveStickinessPolicies(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ params := &cloudstack.ListLBStickinessPoliciesParams{}
+ mockLB.EXPECT().NewListLBStickinessPoliciesParams().Return(params)
+
mockLB.EXPECT().ListLBStickinessPolicies(params).Return(&cloudstack.ListLBStickinessPoliciesResponse{
+ Count: 1,
+ LBStickinessPolicies:
[]*cloudstack.LBStickinessPolicy{{Stickinesspolicy:
[]cloudstack.LBStickinessPolicyStickinesspolicy{
+ {Id: "revoked", State: "Revoked"},
+ {Id: "live"},
+ }}},
+ }, nil)
+
+ lb := &loadBalancer{CloudStackClient:
&cloudstack.CloudStackClient{LoadBalancer: mockLB}}
+ live, err := lb.liveStickinessPolicies("rule-1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(live) != 1 || live[0].Id != "live" {
+ t.Errorf("live = %+v, want only the live policy", live)
+ }
+ if ruleID, _ := params.GetLbruleid(); ruleID != "rule-1" {
+ t.Errorf("lbruleid = %q, want rule-1", ruleID)
+ }
+}
+
+// stickinessTestRule returns an up-to-date TCP rule of the "atestuid" load
balancer for a port.
+func stickinessTestRule(id string, port corev1.ServicePort)
*cloudstack.LoadBalancerRule {
+ return &cloudstack.LoadBalancerRule{
+ Id: id,
+ Name: fmt.Sprintf("atestuid-tcp-%d", port.Port),
+ Publicip: "10.0.0.1",
+ Publicipid: "ip-1",
+ Publicport: fmt.Sprint(port.Port),
+ Privateport: fmt.Sprint(port.NodePort),
+ Cidrlist: defaultAllowedCIDR,
+ Algorithm: "roundrobin",
+ Protocol: "tcp",
+ Networkid: "net-1",
+ }
+}
+
+// firewallRulesFor returns firewall rules that already open the given ports
to everyone.
+func firewallRulesFor(ports ...corev1.ServicePort)
*cloudstack.ListFirewallRulesResponse {
+ resp := &cloudstack.ListFirewallRulesResponse{}
+ for _, port := range ports {
+ resp.FirewallRules = append(resp.FirewallRules,
&cloudstack.FirewallRule{
+ Id: fmt.Sprintf("fw-%d", port.Port),
+ Protocol: "tcp",
+ Startport: int(port.Port),
+ Endport: int(port.Port),
+ Cidrlist: defaultAllowedCIDR,
+ })
+ }
+ resp.Count = len(resp.FirewallRules)
+
+ return resp
+}
+
+// stickinessAnnotations returns the stickiness annotations that are not empty.
+func stickinessAnnotations(method, params string) map[string]string {
+ return stickinessService(method, params).Annotations
+}
+
+// withAppProtocol returns the port declaring the given application protocol.
+func withAppProtocol(port corev1.ServicePort, appProtocol string)
corev1.ServicePort {
+ port.AppProtocol = &appProtocol
+ return port
+}
+
+func TestEnsureLoadBalancerStickiness(t *testing.T) {
+ http := withAppProtocol(corev1.ServicePort{Name: "http", Port: 80,
NodePort: 30000, Protocol: corev1.ProtocolTCP}, "http")
+ alt := corev1.ServicePort{Name: "alt", Port: 8080, NodePort: 30080,
Protocol: corev1.ProtocolTCP}
+ cookie := map[string]string{"cookie-name": "SERVERID"}
+ ours := func(params map[string]string)
cloudstack.LBStickinessPolicyStickinesspolicy {
+ return cloudstack.LBStickinessPolicyStickinesspolicy{Id:
"policy-ours", Name: "atestuid-tcp-80", Methodname: "LbCookie", Params: params,
Description: stickinessPolicyDescription}
+ }
+ manual := func(id string) cloudstack.LBStickinessPolicyStickinesspolicy
{
+ return cloudstack.LBStickinessPolicyStickinesspolicy{Id: id,
Name: "manual", Methodname: "LbCookie", Params: cookie}
+ }
+ newEnv := func(t *testing.T, annotations map[string]string, ports
...corev1.ServicePort) *ensureLBTestEnv {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ return newEnsureLBTestEnv(ctrl, annotations, ports)
+ }
+ ensure := func(env *ensureLBTestEnv) error {
+ _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes)
+ return err
+ }
+ expectCreate := func(env *ensureLBTestEnv, ruleID, method, ruleName
string, createErr error) *cloudstack.CreateLBStickinessPolicyParams {
+ params := &cloudstack.CreateLBStickinessPolicyParams{}
+ env.lb.EXPECT().NewCreateLBStickinessPolicyParams(ruleID,
method, ruleName).Return(params)
+
env.lb.EXPECT().CreateLBStickinessPolicy(params).Return(&cloudstack.CreateLBStickinessPolicyResponse{},
createErr)
+ return params
+ }
+ expectDelete := func(env *ensureLBTestEnv, policyID string) {
+ params := &cloudstack.DeleteLBStickinessPolicyParams{}
+
env.lb.EXPECT().NewDeleteLBStickinessPolicyParams(policyID).Return(params)
+
env.lb.EXPECT().DeleteLBStickinessPolicy(params).Return(&cloudstack.DeleteLBStickinessPolicyResponse{},
nil)
+ }
+ sentParams := func(t *testing.T, params
*cloudstack.CreateLBStickinessPolicyParams) map[string]string {
+ t.Helper()
+ if description, _ := params.GetDescription(); description !=
stickinessPolicyDescription {
+ t.Errorf("policy description = %q, want the
controller's marker", description)
+ }
+ sent, _ := params.GetParam()
+ return sent
+ }
+
+ t.Run("a policy is created once the rule and its firewall are in
place", func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("lbcookie",
"Cookie-Name=SERVERID, nocache=1"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ create := &cloudstack.CreateLBStickinessPolicyParams{}
+ gomock.InOrder(
+
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
+
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(firewallRulesFor(http),
nil),
+
env.lb.EXPECT().NewCreateLBStickinessPolicyParams("rule-1", "LbCookie",
"atestuid-tcp-80").Return(create),
+
env.lb.EXPECT().CreateLBStickinessPolicy(create).Return(&cloudstack.CreateLBStickinessPolicyResponse{},
nil),
+ )
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if sent := sentParams(t, create); !maps.Equal(sent,
map[string]string{"cookie-name": "SERVERID", "nocache": "true"}) {
+ t.Errorf("sent params = %v, want the normalised
cookie-name and nocache=true", sent)
+ }
+ if !slices.Equal(env.policyLookups, []string{"rule-1"}) {
+ t.Errorf("policy lookups = %v, want one for rule-1",
env.policyLookups)
+ }
+ })
+
+ t.Run("the controller's matching policy is left alone", func(t
*testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{ours(cookie)}
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("a changed parameter is swapped by one create, with no delete",
func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{ours(map[string]string{"cookie-name":
"OLD"})}
+ create := expectCreate(env, "rule-1", "LbCookie",
"atestuid-tcp-80", nil)
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if sent := sentParams(t, create); !maps.Equal(sent, cookie) {
+ t.Errorf("sent params = %v, want %v", sent, cookie)
+ }
+ })
+
+ t.Run("a hand-made policy on a selected port is replaced by the
controller's", func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{manual("policy-manual")}
+ expectCreate(env, "rule-1", "LbCookie", "atestuid-tcp-80", nil)
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("a hand-made policy on an unannotated Service is kept", func(t
*testing.T) {
+ env := newEnv(t, nil, http)
+ env.expectHostsAndNetwork()
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{manual("policy-manual")}
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("removing the annotation deletes only the controller's policy",
func(t *testing.T) {
+ env := newEnv(t, nil, http)
+ env.expectHostsAndNetwork()
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{ours(cookie),
manual("policy-manual")}
+ expectDelete(env, "policy-ours")
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("LbCookie follows appProtocol http and leaves other ports",
func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), withAppProtocol(http, "https"), withAppProtocol(alt,
"http"))
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http),
stickinessTestRule("rule-2", alt))
+ env.expectFirewallListings(firewallRulesFor(http, alt), 2)
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{ours(cookie)}
+ expectDelete(env, "policy-ours")
+ expectCreate(env, "rule-2", "LbCookie", "atestuid-tcp-8080",
nil)
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("LbCookie on ports declaring no appProtocol fails before any
CloudStack call", func(t *testing.T) {
+ undeclared := http
+ undeclared.AppProtocol = nil
+ env := newEnv(t, stickinessAnnotations("LbCookie", ""),
undeclared, alt)
+
+ if err := ensure(env); err == nil ||
!strings.Contains(err.Error(), "appProtocol: http") {
+ t.Fatalf("error = %v, want it to ask for appProtocol:
http", err)
+ }
+ })
+
+ t.Run("an invalid parameter fails before any CloudStack call", func(t
*testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name="), http)
+
+ if err := ensure(env); err == nil ||
!strings.Contains(err.Error(), "missing value") {
+ t.Fatalf("error = %v, want the empty value rejected",
err)
+ }
+ })
+
+ t.Run("a method the network does not offer fails before any write",
func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("NoSuchMethod", ""),
http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+
+ if err := ensure(env); err == nil ||
!strings.Contains(err.Error(), "supported methods: LbCookie, SourceBased") {
+ t.Fatalf("error = %v, want it to list the supported
methods", err)
+ }
+ })
+
+ t.Run("a false flag is not sent", func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID,nocache=false"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ create := expectCreate(env, "rule-1", "LbCookie",
"atestuid-tcp-80", nil)
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if sent := sentParams(t, create); !maps.Equal(sent, cookie) {
+ t.Errorf("sent params = %v, want %v without nocache",
sent, cookie)
+ }
+ })
+
+ t.Run("without the capability parameters pass through unchanged",
func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"nocache=false"), http)
+ env.expectHostsAndNetwork()
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ create := expectCreate(env, "rule-1", "LbCookie",
"atestuid-tcp-80", nil)
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if sent := sentParams(t, create); !maps.Equal(sent,
map[string]string{"nocache": "false"}) {
+ t.Errorf("sent params = %v, want nocache=false passed
through", sent)
+ }
+ })
+
+ t.Run("a rejected policy keeps the rule and its firewall opening",
func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("SourceBased",
"tablesize=abc"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ create := &cloudstack.CreateLBStickinessPolicyParams{}
+ gomock.InOrder(
+
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
+
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(firewallRulesFor(http),
nil),
+
env.lb.EXPECT().NewCreateLBStickinessPolicyParams("rule-1", "SourceBased",
"atestuid-tcp-80").Return(create),
+
env.lb.EXPECT().CreateLBStickinessPolicy(create).Return(nil,
fmt.Errorf("CloudStack API error 431: tablesize is not in size format")),
+ )
+
+ err := ensure(env)
+ if err == nil || !strings.Contains(err.Error(), "error creating
stickiness policy") || !strings.Contains(err.Error(), "tablesize") {
+ t.Fatalf("error = %v, want CloudStack's rejection of
the policy", err)
+ }
+ })
+
+ t.Run("every port's failure is reported together", func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("SourceBased",
"tablesize=200k"), http, alt)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http),
stickinessTestRule("rule-2", alt))
+ env.expectFirewallListings(firewallRulesFor(http, alt), 2)
+ expectCreate(env, "rule-1", "SourceBased", "atestuid-tcp-80",
fmt.Errorf("router A unavailable"))
+ expectCreate(env, "rule-2", "SourceBased", "atestuid-tcp-8080",
fmt.Errorf("router B unavailable"))
+
+ err := ensure(env)
+ if err == nil || !strings.Contains(err.Error(), "router A
unavailable") || !strings.Contains(err.Error(), "router B unavailable") {
+ t.Fatalf("error = %v, want both ports' failures", err)
+ }
+ })
+
+ t.Run("a recreated rule gets its policy without a lookup", func(t
*testing.T) {
+ moved := http
+ moved.NodePort = 30001
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), moved)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ create := &cloudstack.CreateLBStickinessPolicyParams{}
+ gomock.InOrder(
+
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-1").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
+
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{},
nil),
+
env.lb.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin",
"atestuid-tcp-80", 30001,
80).Return(&cloudstack.CreateLoadBalancerRuleParams{}),
+
env.lb.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(&cloudstack.CreateLoadBalancerRuleResponse{
+ Id: "rule-new", Name: "atestuid-tcp-80",
Publicip: "10.0.0.1", Publicipid: "ip-1", Protocol: "tcp",
+ }, nil),
+
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-new").Return(&cloudstack.AssignToLoadBalancerRuleParams{}),
+
env.lb.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{},
nil),
+
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
+
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(firewallRulesFor(http),
nil),
+
env.lb.EXPECT().NewCreateLBStickinessPolicyParams("rule-new", "LbCookie",
"atestuid-tcp-80").Return(create),
+
env.lb.EXPECT().CreateLBStickinessPolicy(create).Return(&cloudstack.CreateLBStickinessPolicyResponse{},
nil),
+ )
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(env.policyLookups) != 0 {
+ t.Errorf("policy lookups = %v, want none for a rule
created in this run", env.policyLookups)
+ }
+ })
+
+ t.Run("a proxy protocol toggle keeps the renamed rule's policy", func(t
*testing.T) {
+ annotations := stickinessAnnotations("LbCookie",
"cookie-name=SERVERID")
+ annotations[ServiceAnnotationLoadBalancerProxyProtocol] = "true"
+ env := newEnv(t, annotations, http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{ours(cookie)}
+ update := &cloudstack.UpdateLoadBalancerRuleParams{}
+
env.lb.EXPECT().NewUpdateLoadBalancerRuleParams("rule-1").Return(update)
+
env.lb.EXPECT().UpdateLoadBalancerRule(update).Return(&cloudstack.UpdateLoadBalancerRuleResponse{},
nil)
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if name, _ := update.GetName(); name != "atestuid-tcp-proxy-80"
{
+ t.Errorf("updated name = %q, want
atestuid-tcp-proxy-80", name)
+ }
+ })
+
+ t.Run("a prune failure still runs the stickiness phase", func(t
*testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ obsolete := stickinessTestRule("rule-obsolete", alt)
+ env.expectRules(obsolete, stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 2)
+
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-obsolete").Return(&cloudstack.DeleteLoadBalancerRuleParams{})
+
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(nil,
fmt.Errorf("delete rule API error"))
+ expectCreate(env, "rule-1", "LbCookie", "atestuid-tcp-80", nil)
+
+ err := ensure(env)
+ if err == nil || !strings.Contains(err.Error(), "delete rule
API error") {
+ t.Fatalf("error = %v, want the prune failure reported",
err)
+ }
+ })
+
+ t.Run("a rule with two policies is reported and keeps both", func(t
*testing.T) {
+ for name, live := range
map[string][]cloudstack.LBStickinessPolicyStickinesspolicy{
+ "ours and a hand-made one":
{ours(map[string]string{"cookie-name": "OLD"}), manual("b")},
+ "two hand-made ones": {manual("a"), manual("b")},
+ } {
+ t.Run(name, func(t *testing.T) {
+ env := newEnv(t,
stickinessAnnotations("LbCookie", "cookie-name=SERVERID"), http)
+
env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1",
http))
+
env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policies["rule-1"] = live
+
+ if err := ensure(env); err == nil ||
!strings.Contains(err.Error(), "remove the extra ones") {
+ t.Fatalf("error = %v, want the hint to
remove the extra policies", err)
+ }
+ })
+ }
+ })
+
+ notAllowed := fmt.Errorf("CloudStack API error 432
(CSExceptionErrorCode: 9999): The API [listLBStickinessPolicies] does not exist
or is not available for the account")
+
+ t.Run("an account without the stickiness APIs still syncs a Service
without stickiness", func(t *testing.T) {
+ env := newEnv(t, nil, http)
+ env.expectHostsAndNetwork()
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policyErr = notAllowed
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("an account without the stickiness APIs gets a clear error when
stickiness is set", func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policyErr = notAllowed
+
+ if err := ensure(env); err == nil ||
!strings.Contains(err.Error(), "stickiness needs the listLBStickinessPolicies")
{
+ t.Fatalf("error = %v, want it to name the missing
APIs", err)
+ }
+ })
+
+ t.Run("other policy lookup errors still fail the sync", func(t
*testing.T) {
+ env := newEnv(t, nil, http)
+ env.expectHostsAndNetwork()
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.policyErr = fmt.Errorf("CloudStack API error 530: internal
error")
+
+ if err := ensure(env); err == nil ||
!strings.Contains(err.Error(), "internal error") {
+ t.Fatalf("error = %v, want the lookup failure", err)
+ }
+ })
+
+ t.Run("a revoked policy is ignored", func(t *testing.T) {
+ env := newEnv(t, stickinessAnnotations("LbCookie",
"cookie-name=SERVERID"), http)
+ env.expectHostsAndNetworkWith(virtualRouterNetwork())
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ revoked := ours(cookie)
+ revoked.State = "Revoked"
+ env.policies["rule-1"] =
[]cloudstack.LBStickinessPolicyStickinesspolicy{revoked}
+ expectCreate(env, "rule-1", "LbCookie", "atestuid-tcp-80", nil)
+
+ if err := ensure(env); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+}
+
+func TestEnsureLoadBalancerRepairsEmptyRules(t *testing.T) {
+ http := corev1.ServicePort{Name: "http", Port: 80, NodePort: 30000,
Protocol: corev1.ProtocolTCP}
+
+ t.Run("an adopted rule with no hosts gets them", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{http})
+ env.expectHostsAndNetwork()
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.instances = nil
+ assign := &cloudstack.AssignToLoadBalancerRuleParams{}
+
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-1").Return(assign)
+
env.lb.EXPECT().AssignToLoadBalancerRule(assign).Return(&cloudstack.AssignToLoadBalancerRuleResponse{},
nil)
+
+ if _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ids, _ := assign.GetVirtualmachineids(); !slices.Equal(ids,
[]string{"vm-1"}) {
+ t.Errorf("assigned VMs = %v, want [vm-1]", ids)
+ }
+ })
+
+ t.Run("an adopted rule with other hosts is left to node sync", func(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{http})
+ env.expectHostsAndNetwork()
+ env.expectRules(stickinessTestRule("rule-1", http))
+ env.expectFirewallListings(firewallRulesFor(http), 1)
+ env.instances = []*cloudstack.VirtualMachine{{Id: "vm-2"}}
+
+ if _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+}
+
+func TestEnsureLoadBalancerFreshIP(t *testing.T) {
+ http := corev1.ServicePort{Name: "http", Port: 80, NodePort: 30000,
Protocol: corev1.ProtocolTCP}
+ freshService := func(t *testing.T, annotations map[string]string,
createRuleErr error) *ensureLBTestEnv {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ env := newEnsureLBTestEnv(ctrl, annotations,
[]corev1.ServicePort{http})
+ env.expectHosts()
+ env.expectRules()
+ env.network.EXPECT().GetNetworkByID("net-1",
gomock.Any()).Return(virtualRouterNetwork(), 1, nil).Times(2)
+
env.address.EXPECT().NewAssociateIpAddressParams().Return(&cloudstack.AssociateIpAddressParams{})
+
env.address.EXPECT().AssociateIpAddress(gomock.Any()).Return(&cloudstack.AssociateIpAddressResponse{Id:
"ip-new", Ipaddress: "10.0.0.9"}, nil)
+ env.lb.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin",
"atestuid-tcp-80", 30000, 80).Return(&cloudstack.CreateLoadBalancerRuleParams{})
+ if createRuleErr != nil {
+
env.lb.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(nil, createRuleErr)
+ return env
+ }
+
env.lb.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(&cloudstack.CreateLoadBalancerRuleResponse{
+ Id: "rule-new", Name: "atestuid-tcp-80", Publicip:
"10.0.0.9", Publicipid: "ip-new", Protocol: "tcp",
+ }, nil)
+
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-new").Return(&cloudstack.AssignToLoadBalancerRuleParams{})
+
env.lb.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{},
nil)
+
env.expectFirewallListings(&cloudstack.ListFirewallRulesResponse{}, 1)
+ env.firewall.EXPECT().NewCreateFirewallRuleParams("ip-new",
"tcp").Return(&cloudstack.CreateFirewallRuleParams{})
+
env.firewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(&cloudstack.CreateFirewallRuleResponse{},
nil)
+ return env
+ }
+
+ t.Run("an unsupported method fails before an IP is associated", func(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ env := newEnsureLBTestEnv(ctrl,
stickinessAnnotations("NoSuchMethod", ""), []corev1.ServicePort{http})
+ env.expectHosts()
+ env.expectRules()
+ env.network.EXPECT().GetNetworkByID("net-1",
gomock.Any()).Return(virtualRouterNetwork(), 1, nil)
+
+ _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes)
+ if err == nil || !strings.Contains(err.Error(), "not supported
on this network") {
+ t.Fatalf("error = %v, want the unsupported method
rejected", err)
+ }
+ })
+
+ t.Run("a rejected policy keeps the new IP and its rules", func(t
*testing.T) {
+ env := freshService(t, stickinessAnnotations("SourceBased",
"tablesize=abc"), nil)
+ env.lb.EXPECT().NewCreateLBStickinessPolicyParams("rule-new",
"SourceBased",
"atestuid-tcp-80").Return(&cloudstack.CreateLBStickinessPolicyParams{})
+
env.lb.EXPECT().CreateLBStickinessPolicy(gomock.Any()).Return(nil,
fmt.Errorf("tablesize is not in size format"))
+
+ _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes)
+ if err == nil || !strings.Contains(err.Error(), "stickiness") {
+ t.Fatalf("error = %v, want the policy rejection", err)
+ }
+ })
+
+ t.Run("a failure before the rules exist still releases the new IP",
func(t *testing.T) {
+ env := freshService(t, nil, fmt.Errorf("create rule API error"))
+ release := &cloudstack.DisassociateIpAddressParams{}
+
env.address.EXPECT().NewDisassociateIpAddressParams("ip-new").Return(release)
+
env.address.EXPECT().DisassociateIpAddress(release).Return(&cloudstack.DisassociateIpAddressResponse{},
nil)
+
+ _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes)
+ if err == nil || !strings.Contains(err.Error(), "create rule
API error") {
+ t.Fatalf("error = %v, want the rule creation failure",
err)
+ }
+ })
+}
diff --git a/docs/development.md b/docs/development.md
index 28757dd1..9727f2c6 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -250,12 +250,14 @@ Both configs set `zone` explicitly. If `zone` is empty
the CCM tries to
detect it by looking up its own pod, which cannot work when running as a host
process.
-The script then loads the image into kind, applies the repository's
-[`deployment.yaml`](../deployment.yaml) and patches it for testing: the local
-image with `imagePullPolicy: Never`, `--leader-elect=false` (single replica,
-faster startup), `--v=4` for useful logs, and higher CPU limits — the stock
-manifest's `limits.cpu: 50m` throttles informer startup badly on shared CI
-runners.
+The script then loads the image into kind and applies the repository's
+[`deployment.yaml`](../deployment.yaml), changed for testing before it is
+applied: the local image with `imagePullPolicy: Never`, `--leader-elect=false`
+(single replica, faster startup), `--v=4` for useful logs, and higher CPU
+limits — the stock manifest's `limits.cpu: 50m` throttles informer startup
+badly on shared CI runners. Applying the stock manifest first would start the
+released image, which can keep running for its 30s grace period and reconcile
+the test Services next to the build under test.
Finally it waits for every node to lose the uninitialized taint.
diff --git a/go.mod b/go.mod
index 24e177ad..be0b8af9 100644
--- a/go.mod
+++ b/go.mod
@@ -3,13 +3,14 @@ module github.com/apache/cloudstack-kubernetes-provider
go 1.23.0
require (
- github.com/apache/cloudstack-go/v2 v2.19.0
+ github.com/apache/cloudstack-go/v2 v2.19.1
github.com/blang/semver/v4 v4.0.0
github.com/spf13/pflag v1.0.5
go.uber.org/mock v0.5.0
gopkg.in/gcfg.v1 v1.2.3
k8s.io/api v0.24.17
k8s.io/apimachinery v0.24.17
+ k8s.io/client-go v0.24.17
k8s.io/cloud-provider v0.24.17
k8s.io/component-base v0.24.17
k8s.io/klog/v2 v2.80.1
@@ -95,7 +96,6 @@ require (
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiserver v0.24.17 // indirect
- k8s.io/client-go v0.24.17 // indirect
k8s.io/component-helpers v0.24.17 // indirect
k8s.io/controller-manager v0.24.17 // indirect
k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 // indirect
diff --git a/go.sum b/go.sum
index 85fd3e0f..423e8ea9 100644
--- a/go.sum
+++ b/go.sum
@@ -52,8 +52,8 @@ github.com/alecthomas/units
v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod
h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod
h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/antihax/optional v1.0.0/go.mod
h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/apache/cloudstack-go/v2 v2.19.0
h1:YHLw770MmgiqXx6NRFYw2Nr7DpnylLhLG2KYNCftgnc=
-github.com/apache/cloudstack-go/v2 v2.19.0/go.mod
h1:p/YBUwIEkQN6CQxFhw8Ff0wzf1MY0qRRRuGYNbcb1F8=
+github.com/apache/cloudstack-go/v2 v2.19.1
h1:1K5O4NZpdWzOZUN6XuaVNsdX+QnoFRc5VE/oc1nUckQ=
+github.com/apache/cloudstack-go/v2 v2.19.1/go.mod
h1:p/YBUwIEkQN6CQxFhw8Ff0wzf1MY0qRRRuGYNbcb1F8=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod
h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/benbjohnson/clock v1.0.3/go.mod
h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM=
github.com/benbjohnson/clock v1.1.0
h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
diff --git a/hack/e2e/40-ccm-deploy.sh b/hack/e2e/40-ccm-deploy.sh
index adaa27c3..7dc080fb 100755
--- a/hack/e2e/40-ccm-deploy.sh
+++ b/hack/e2e/40-ccm-deploy.sh
@@ -68,19 +68,22 @@ kubectl -n kube-system create secret generic
cloudstack-secret \
--from-file=cloud-config="${E2E_OUT}/cloud-config" \
--dry-run=client -o yaml | kubectl apply -f -
-kubectl apply -f "${REPO_ROOT}/deployment.yaml"
-# Adjust the stock manifest for e2e: local image, no leader election (single
-# replica, faster startup), verbose logs, and enough CPU that informer startup
-# is not throttled on shared runners.
-kubectl -n kube-system patch deployment cloud-controller-manager --type=json
-p '[
-
{"op":"replace","path":"/spec/template/spec/containers/0/image","value":"'"$CCM_IMAGE"'"},
-
{"op":"replace","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"Never"},
- {"op":"replace","path":"/spec/template/spec/containers/0/args","value":[
-
"--cloud-provider=external-cloudstack","--cloud-config=/config/cloud-config",
- "--leader-elect=false","--v=4"]},
- {"op":"replace","path":"/spec/template/spec/containers/0/resources","value":{
-
"requests":{"cpu":"100m","memory":"128Mi"},"limits":{"cpu":"1","memory":"512Mi"}}}
-]'
+# Adjust the stock manifest for e2e before applying it: local image, no leader
+# election (single replica, faster startup), verbose logs, and enough CPU that
+# informer startup is not throttled on shared runners. Applying it unchanged
+# first would start the released image, which can keep running for its whole
+# 30s grace period and reconcile the test Services next to the build under
test.
+kubectl create --dry-run=client -o json -f "${REPO_ROOT}/deployment.yaml" |
+ jq -s --arg image "$CCM_IMAGE" '{apiVersion: "v1", kind: "List", items:
map(
+ if .kind != "Deployment" then . else
+ .spec.template.spec.containers[0] |= (
+ .image = $image
+ | .imagePullPolicy = "Never"
+ | .args = ["--cloud-provider=external-cloudstack",
"--cloud-config=/config/cloud-config",
+ "--leader-elect=false", "--v=4"]
+ | .resources = {requests: {cpu: "100m", memory: "128Mi"},
limits: {cpu: "1", memory: "512Mi"}})
+ end)}' |
+ kubectl apply -f -
kubectl -n kube-system rollout status deployment/cloud-controller-manager
--timeout=180s
diff --git a/test/e2e/annotations_test.go b/test/e2e/annotations_test.go
index f5513487..05f04f31 100644
--- a/test/e2e/annotations_test.go
+++ b/test/e2e/annotations_test.go
@@ -24,6 +24,7 @@ package e2e
import (
"context"
"fmt"
+ "maps"
"strings"
"testing"
@@ -33,10 +34,12 @@ import (
)
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"
+ 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) {
@@ -134,6 +137,166 @@ func TestAnnot_SessionAffinity(t *testing.T) {
})
}
+// 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)
diff --git a/test/e2e/framework.go b/test/e2e/framework.go
index dbf7ecb9..bed2cea8 100644
--- a/test/e2e/framework.go
+++ b/test/e2e/framework.go
@@ -39,6 +39,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
+ "maps"
"os"
"strconv"
"strings"
@@ -57,6 +58,13 @@ import (
const (
lbSyncTimeout = 3 * time.Minute
lbSyncInterval = 3 * time.Second
+
+ // resyncAnnotation is changed by ResyncAndWait to make the service
controller sync a Service
+ // again. The CCM ignores it.
+ resyncAnnotation = "e2e.cloudstack.apache.org/resync"
+
+ // stickinessPolicyMarker is the description the CCM gives the
stickiness policies it creates.
+ stickinessPolicyMarker = "Managed by the CloudStack Kubernetes Provider"
)
// Framework bundles the clients and helpers shared by all e2e tests.
@@ -364,6 +372,163 @@ func (f *Framework) WaitForLBRules(lbName string, want
int) []*cloudstack.LoadBa
return rules
}
+// StickinessPolicies lists the stickiness policies of a rule, skipping
revoked ones CloudStack
+// has not removed yet.
+func (f *Framework) StickinessPolicies(ruleID string)
([]cloudstack.LBStickinessPolicyStickinesspolicy, error) {
+ p := f.CS.LoadBalancer.NewListLBStickinessPoliciesParams()
+ p.SetLbruleid(ruleID)
+ resp, err := f.CS.LoadBalancer.ListLBStickinessPolicies(p)
+ if err != nil {
+ return nil, err
+ }
+ var live []cloudstack.LBStickinessPolicyStickinesspolicy
+ for _, wrapper := range resp.LBStickinessPolicies {
+ for _, policy := range wrapper.Stickinesspolicy {
+ if !strings.EqualFold(policy.State, "Revoked") {
+ live = append(live, policy)
+ }
+ }
+ }
+ return live, nil
+}
+
+// WaitForStickinessPolicy waits until the rule has exactly one policy, with
the given method and
+// parameters, and returns it.
+func (f *Framework) WaitForStickinessPolicy(ruleID, method string, params
map[string]string) cloudstack.LBStickinessPolicyStickinesspolicy {
+ f.T.Helper()
+ var policy cloudstack.LBStickinessPolicyStickinesspolicy
+ f.Eventually(lbSyncTimeout, lbSyncInterval,
+ fmt.Sprintf("rule %s to carry one %s stickiness policy with
params %v", ruleID, method, params),
+ func() (bool, error) {
+ live, err := f.StickinessPolicies(ruleID)
+ if err != nil {
+ return false, err
+ }
+ if len(live) != 1 {
+ return false, fmt.Errorf("rule has %d live
policies", len(live))
+ }
+ if !strings.EqualFold(live[0].Methodname, method) ||
!maps.Equal(live[0].Params, params) {
+ return false, fmt.Errorf("policy is %s %v",
live[0].Methodname, live[0].Params)
+ }
+ policy = live[0]
+ return true, nil
+ })
+ return policy
+}
+
+// WaitForNoStickinessPolicy waits until the rule has no stickiness policy.
+func (f *Framework) WaitForNoStickinessPolicy(ruleID string) {
+ f.T.Helper()
+ f.Eventually(lbSyncTimeout, lbSyncInterval, fmt.Sprintf("rule %s to
carry no stickiness policy", ruleID),
+ func() (bool, error) {
+ live, err := f.StickinessPolicies(ruleID)
+ if err != nil {
+ return false, err
+ }
+ if len(live) != 0 {
+ return false, fmt.Errorf("rule has %d live
policies", len(live))
+ }
+ return true, nil
+ })
+}
+
+// CreateStickinessPolicy adds a policy to a rule through the CloudStack API,
the way an operator
+// would by hand, and returns its ID.
+func (f *Framework) CreateStickinessPolicy(ruleID, name, method string, params
map[string]string) string {
+ f.T.Helper()
+ p := f.CS.LoadBalancer.NewCreateLBStickinessPolicyParams(ruleID,
method, name)
+ p.SetParam(params)
+ resp, err := f.CS.LoadBalancer.CreateLBStickinessPolicy(p)
+ if err != nil {
+ f.T.Fatalf("creating stickiness policy %s on rule %s: %v",
name, ruleID, err)
+ }
+ if len(resp.Stickinesspolicy) == 0 {
+ f.T.Fatalf("creating stickiness policy %s on rule %s returned
no policy", name, ruleID)
+ }
+ return resp.Stickinesspolicy[0].Id
+}
+
+// ResyncAndWait makes the service controller sync svc again, by changing an
annotation the CCM
+// ignores, and waits for the sync to succeed. EnsureLoadBalancer only runs
when a Service changes
+// or the controller restarts, so tests need this to check what a sync leaves
alone.
+func (f *Framework) ResyncAndWait(svc *corev1.Service) {
+ f.T.Helper()
+ before, err := f.ensuredCount(svc)
+ if err != nil {
+ f.T.Fatalf("counting reconciles of service %s/%s: %v",
svc.Namespace, svc.Name, err)
+ }
+ f.UpdateService(svc, func(s *corev1.Service) {
+ if s.Annotations == nil {
+ s.Annotations = map[string]string{}
+ }
+ s.Annotations[resyncAnnotation] =
strconv.FormatInt(time.Now().UnixNano(), 10)
+ })
+ f.Eventually(lbSyncTimeout, lbSyncInterval, fmt.Sprintf("service %s/%s
to be reconciled again", svc.Namespace, svc.Name),
+ func() (bool, error) {
+ after, err := f.ensuredCount(svc)
+ return after > before, err
+ })
+}
+
+// ensuredCount counts the successful syncs recorded for svc, including
repeats that Kubernetes
+// folded into one event.
+func (f *Framework) ensuredCount(svc *corev1.Service) (int32, error) {
+ events, err :=
f.K8s.CoreV1().Events(svc.Namespace).List(context.Background(),
metav1.ListOptions{
+ FieldSelector: "involvedObject.name=" + svc.Name +
",reason=EnsuredLoadBalancer",
+ })
+ if err != nil {
+ return 0, err
+ }
+ var total int32
+ for _, event := range events.Items {
+ count := max(event.Count, 1)
+ if event.Series != nil {
+ count = max(count, event.Series.Count)
+ }
+ total += count
+ }
+ return total, nil
+}
+
+// WaitForSyncFailure waits until a failed sync of svc is reported with a
message containing substr.
+func (f *Framework) WaitForSyncFailure(svc *corev1.Service, substr string) {
+ f.T.Helper()
+ f.Eventually(lbSyncTimeout, lbSyncInterval, fmt.Sprintf("a failed sync
of service %s/%s mentioning %q", svc.Namespace, svc.Name, substr),
+ func() (bool, error) {
+ events, err :=
f.K8s.CoreV1().Events(svc.Namespace).List(context.Background(),
metav1.ListOptions{
+ FieldSelector: "involvedObject.name=" +
svc.Name + ",reason=SyncLoadBalancerFailed",
+ })
+ if err != nil {
+ return false, err
+ }
+ for _, event := range events.Items {
+ if strings.Contains(event.Message, substr) {
+ return true, nil
+ }
+ }
+ return false, nil
+ })
+}
+
+// ruleIDsByPort maps the public port of each load balancer rule to the rule's
id.
+func ruleIDsByPort(rules []*cloudstack.LoadBalancerRule) map[string]string {
+ ids := make(map[string]string, len(rules))
+ for _, rule := range rules {
+ ids[rule.Publicport] = rule.Id
+ }
+ return ids
+}
+
+// RuleInstanceCount returns how many VMs are assigned to a load balancer rule.
+func (f *Framework) RuleInstanceCount(ruleID string) (int, error) {
+ p := f.CS.LoadBalancer.NewListLoadBalancerRuleInstancesParams(ruleID)
+ resp, err := f.CS.LoadBalancer.ListLoadBalancerRuleInstances(p)
+ if err != nil {
+ return 0, err
+ }
+ return len(resp.LoadBalancerRuleInstances), nil
+}
+
// FirewallRules lists the firewall rules on a public IP.
func (f *Framework) FirewallRules(publicIPID string)
([]*cloudstack.FirewallRule, error) {
p := f.CS.Firewall.NewListFirewallRulesParams()
diff --git a/test/e2e/loadbalancer_test.go b/test/e2e/loadbalancer_test.go
index b4918b17..5c5d0182 100644
--- a/test/e2e/loadbalancer_test.go
+++ b/test/e2e/loadbalancer_test.go
@@ -161,6 +161,54 @@ func TestLB_NodeMembership(t *testing.T) {
})
}
+// A rule left without hosts, for example because assigning them failed after
+// the rule was created, gets them back on the next sync instead of waiting for
+// a node change.
+func TestLB_HostsRestoredOnResync(t *testing.T) {
+ f := NewFramework(t)
+ svc := f.CreateLBService(nil)
+ lbName := defaultLoadBalancerName(svc)
+
+ f.WaitForIngressIP(svc)
+ rules := f.WaitForLBRules(lbName, 1)
+ var assigned []string
+ f.Eventually(lbSyncTimeout, lbSyncInterval, "hosts to be assigned to
the rule",
+ func() (bool, error) {
+ p :=
f.CS.LoadBalancer.NewListLoadBalancerRuleInstancesParams(rules[0].Id)
+ resp, err :=
f.CS.LoadBalancer.ListLoadBalancerRuleInstances(p)
+ if err != nil {
+ return false, err
+ }
+ assigned = assigned[:0]
+ for _, inst := range resp.LoadBalancerRuleInstances {
+ assigned = append(assigned, inst.Id)
+ }
+ return len(assigned) > 0, nil
+ })
+
+ remove :=
f.CS.LoadBalancer.NewRemoveFromLoadBalancerRuleParams(rules[0].Id)
+ remove.SetVirtualmachineids(assigned)
+ if _, err := f.CS.LoadBalancer.RemoveFromLoadBalancerRule(remove); err
!= nil {
+ t.Fatalf("removing hosts from rule %s: %v", rules[0].Id, err)
+ }
+ if got, err := f.RuleInstanceCount(rules[0].Id); err != nil || got != 0
{
+ t.Fatalf("rule still has %d hosts after removal (err %v)", got,
err)
+ }
+
+ forceReconcile(f, svc, lbName)
+ f.Eventually(lbSyncTimeout, lbSyncInterval, "the sync to restore the
rule's hosts",
+ func() (bool, error) {
+ got, err := f.RuleInstanceCount(rules[0].Id)
+ if err != nil {
+ return false, err
+ }
+ if got != len(assigned) {
+ return false, fmt.Errorf("rule has %d hosts,
want %d", got, len(assigned))
+ }
+ return true, nil
+ })
+}
+
func TestLB_PortChange(t *testing.T) {
f := NewFramework(t)
svc := f.CreateLBService(nil)