blob: 24e8a14aaddc29359277b4801709b590b6918492 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package cloudstack
import (
"context"
"fmt"
"maps"
"reflect"
"slices"
"sort"
"strings"
"testing"
"github.com/apache/cloudstack-go/v2/cloudstack"
"github.com/blang/semver/v4"
"go.uber.org/mock/gomock"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestCompareStringSlice(t *testing.T) {
tests := []struct {
name string
x []string
y []string
want bool
}{
{
name: "equal slices same order",
x: []string{"a", "b", "c"},
y: []string{"a", "b", "c"},
want: true,
},
{
name: "equal slices different order",
x: []string{"a", "b", "c"},
y: []string{"c", "a", "b"},
want: true,
},
{
name: "different lengths",
x: []string{"a", "b"},
y: []string{"a", "b", "c"},
want: false,
},
{
name: "same length different elements",
x: []string{"a", "b", "c"},
y: []string{"a", "b", "d"},
want: false,
},
{
name: "both empty",
x: []string{},
y: []string{},
want: true,
},
{
name: "both nil",
x: nil,
y: nil,
want: true,
},
{
name: "one nil one empty",
x: nil,
y: []string{},
want: true,
},
{
name: "one empty one non-empty",
x: []string{},
y: []string{"a"},
want: false,
},
{
name: "duplicate elements equal",
x: []string{"a", "a", "b"},
y: []string{"a", "b", "a"},
want: true,
},
{
name: "duplicate elements not equal - different counts",
x: []string{"a", "a", "b"},
y: []string{"a", "b", "b"},
want: false,
},
{
name: "single element equal",
x: []string{"a"},
y: []string{"a"},
want: true,
},
{
name: "single element not equal",
x: []string{"a"},
y: []string{"b"},
want: false,
},
{
name: "CIDR list comparison - typical use case",
x: []string{"10.0.0.0/8", "192.168.0.0/16"},
y: []string{"192.168.0.0/16", "10.0.0.0/8"},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := compareStringSlice(tt.x, tt.y); got != tt.want {
t.Errorf("compareStringSlice(%v, %v) = %v, want %v", tt.x, tt.y, got, tt.want)
}
})
}
}
func TestSymmetricDifference(t *testing.T) {
tests := []struct {
name string
hostIDs []string
lbInstances []*cloudstack.VirtualMachine
wantAssign []string
wantRemove []string
}{
{
name: "no hosts no instances",
hostIDs: []string{},
lbInstances: []*cloudstack.VirtualMachine{},
wantAssign: nil,
wantRemove: nil,
},
{
name: "all new hosts",
hostIDs: []string{"host1", "host2", "host3"},
lbInstances: []*cloudstack.VirtualMachine{},
wantAssign: []string{"host1", "host2", "host3"},
wantRemove: nil,
},
{
name: "all hosts to remove",
hostIDs: []string{},
lbInstances: []*cloudstack.VirtualMachine{
{Id: "host1"},
{Id: "host2"},
},
wantAssign: nil,
wantRemove: []string{"host1", "host2"},
},
{
name: "exact match - nothing to do",
hostIDs: []string{"host1", "host2"},
lbInstances: []*cloudstack.VirtualMachine{
{Id: "host1"},
{Id: "host2"},
},
wantAssign: nil,
wantRemove: nil,
},
{
name: "partial overlap - some to add some to remove",
hostIDs: []string{"host1", "host3"},
lbInstances: []*cloudstack.VirtualMachine{
{Id: "host1"},
{Id: "host2"},
},
wantAssign: []string{"host3"},
wantRemove: []string{"host2"},
},
{
// Paging over a changing result set can return the same instance on
// two pages. A wanted host must not end up in remove because of it.
name: "duplicate instance of a wanted host",
hostIDs: []string{"host1", "host2"},
lbInstances: []*cloudstack.VirtualMachine{
{Id: "host1"},
{Id: "host2"},
{Id: "host1"},
},
wantAssign: nil,
wantRemove: nil,
},
{
name: "duplicate instance of an unwanted host",
hostIDs: []string{"host1"},
lbInstances: []*cloudstack.VirtualMachine{
{Id: "host1"},
{Id: "host2"},
{Id: "host2"},
},
wantAssign: nil,
wantRemove: []string{"host2"},
},
{
name: "add one host",
hostIDs: []string{"host1", "host2", "host3"},
lbInstances: []*cloudstack.VirtualMachine{
{Id: "host1"},
{Id: "host2"},
},
wantAssign: []string{"host3"},
wantRemove: nil,
},
{
name: "remove one host",
hostIDs: []string{"host1"},
lbInstances: []*cloudstack.VirtualMachine{
{Id: "host1"},
{Id: "host2"},
},
wantAssign: nil,
wantRemove: []string{"host2"},
},
{
name: "nil instances",
hostIDs: []string{"host1"},
lbInstances: nil,
wantAssign: []string{"host1"},
wantRemove: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotAssign, gotRemove := symmetricDifference(tt.hostIDs, tt.lbInstances)
// Sort slices for comparison since map iteration order is not guaranteed
sort.Strings(gotAssign)
sort.Strings(tt.wantAssign)
sort.Strings(gotRemove)
sort.Strings(tt.wantRemove)
if !compareStringSlice(gotAssign, tt.wantAssign) {
t.Errorf("symmetricDifference() assign = %v, want %v", gotAssign, tt.wantAssign)
}
if !compareStringSlice(gotRemove, tt.wantRemove) {
t.Errorf("symmetricDifference() remove = %v, want %v", gotRemove, tt.wantRemove)
}
})
}
}
func TestIsFirewallSupported(t *testing.T) {
tests := []struct {
name string
services []cloudstack.NetworkServiceInternal
want bool
}{
{
name: "empty services",
services: []cloudstack.NetworkServiceInternal{},
want: false,
},
{
name: "nil services",
services: nil,
want: false,
},
{
name: "firewall present",
services: []cloudstack.NetworkServiceInternal{
{Name: "Dhcp"},
{Name: "Firewall"},
{Name: "Dns"},
},
want: true,
},
{
name: "firewall not present",
services: []cloudstack.NetworkServiceInternal{
{Name: "Dhcp"},
{Name: "Dns"},
{Name: "Lb"},
},
want: false,
},
{
name: "only firewall",
services: []cloudstack.NetworkServiceInternal{
{Name: "Firewall"},
},
want: true,
},
{
name: "case sensitive - lowercase firewall",
services: []cloudstack.NetworkServiceInternal{
{Name: "firewall"},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isFirewallSupported(tt.services); got != tt.want {
t.Errorf("isFirewallSupported() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsNetworkACLSupported(t *testing.T) {
tests := []struct {
name string
services []cloudstack.NetworkServiceInternal
want bool
}{
{
name: "empty services",
services: []cloudstack.NetworkServiceInternal{},
want: false,
},
{
name: "nil services",
services: nil,
want: false,
},
{
name: "NetworkACL present",
services: []cloudstack.NetworkServiceInternal{
{Name: "Dhcp"},
{Name: "NetworkACL"},
{Name: "Dns"},
},
want: true,
},
{
name: "NetworkACL not present",
services: []cloudstack.NetworkServiceInternal{
{Name: "Dhcp"},
{Name: "Dns"},
{Name: "Firewall"},
},
want: false,
},
{
name: "only NetworkACL",
services: []cloudstack.NetworkServiceInternal{
{Name: "NetworkACL"},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isNetworkACLSupported(tt.services); got != tt.want {
t.Errorf("isNetworkACLSupported() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetStringFromServiceAnnotation(t *testing.T) {
tests := []struct {
name string
annotations map[string]string
annotationKey string
defaultSetting string
want string
}{
{
name: "annotation present",
annotations: map[string]string{"key1": "value1"},
annotationKey: "key1",
defaultSetting: "default",
want: "value1",
},
{
name: "annotation not present - use default",
annotations: map[string]string{"other": "value"},
annotationKey: "key1",
defaultSetting: "default",
want: "default",
},
{
name: "annotation present but empty - return empty",
annotations: map[string]string{"key1": ""},
annotationKey: "key1",
defaultSetting: "default",
want: "",
},
{
name: "nil annotations - use default",
annotations: nil,
annotationKey: "key1",
defaultSetting: "default",
want: "default",
},
{
name: "empty default when not found",
annotations: map[string]string{},
annotationKey: "key1",
defaultSetting: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
Annotations: tt.annotations,
},
}
if got := getStringFromServiceAnnotation(service, tt.annotationKey, tt.defaultSetting); got != tt.want {
t.Errorf("getStringFromServiceAnnotation() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetBoolFromServiceAnnotation(t *testing.T) {
tests := []struct {
name string
annotations map[string]string
annotationKey string
defaultSetting bool
want bool
}{
{
name: "annotation true",
annotations: map[string]string{"key1": "true"},
annotationKey: "key1",
defaultSetting: false,
want: true,
},
{
name: "annotation false",
annotations: map[string]string{"key1": "false"},
annotationKey: "key1",
defaultSetting: true,
want: false,
},
{
name: "annotation not present - use default true",
annotations: map[string]string{},
annotationKey: "key1",
defaultSetting: true,
want: true,
},
{
name: "annotation not present - use default false",
annotations: map[string]string{},
annotationKey: "key1",
defaultSetting: false,
want: false,
},
{
name: "invalid value - use default true",
annotations: map[string]string{"key1": "invalid"},
annotationKey: "key1",
defaultSetting: true,
want: true,
},
{
name: "invalid value - use default false",
annotations: map[string]string{"key1": "yes"},
annotationKey: "key1",
defaultSetting: false,
want: false,
},
{
name: "empty value - use default",
annotations: map[string]string{"key1": ""},
annotationKey: "key1",
defaultSetting: true,
want: true,
},
{
name: "nil annotations - use default",
annotations: nil,
annotationKey: "key1",
defaultSetting: true,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
Annotations: tt.annotations,
},
}
if got := getBoolFromServiceAnnotation(service, tt.annotationKey, tt.defaultSetting); got != tt.want {
t.Errorf("getBoolFromServiceAnnotation() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetCIDRList(t *testing.T) {
tests := []struct {
name string
annotations map[string]string
want []string
wantErr bool
errContains string
expectEmpty bool
}{
{
name: "defaults to allow all when annotation missing",
annotations: nil,
want: []string{defaultAllowedCIDR},
},
{
name: "trims and splits cidrs",
annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: "10.0.0.0/8, 192.168.0.0/16",
},
want: []string{"10.0.0.0/8", "192.168.0.0/16"},
},
{
name: "empty annotation returns empty list",
annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: "",
},
expectEmpty: true,
},
{
name: "invalid cidr returns error",
annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: "invalid-cidr",
},
wantErr: true,
errContains: "invalid CIDR",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lb := &loadBalancer{}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "svc",
Namespace: "default",
Annotations: tt.annotations,
},
}
got, err := lb.getCIDRList(svc)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("error = %v, expected to contain %q", err, tt.errContains)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.expectEmpty {
if len(got) != 0 {
t.Fatalf("expected empty CIDR list, got %v", got)
}
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("getCIDRList() = %v, want %v", got, tt.want)
}
})
}
}
func TestCheckLoadBalancerRule(t *testing.T) {
port := corev1.ServicePort{Port: 80, NodePort: 30000, Protocol: corev1.ProtocolTCP}
belowCIDRUpdate := semver.MustParse("4.21.0")
withCIDRUpdate := semver.MustParse("4.22.0")
existingRule := func(edit func(*cloudstack.LoadBalancerRule)) *cloudstack.LoadBalancerRule {
lbRule := &cloudstack.LoadBalancerRule{
Id: "rule-id",
Name: "rule",
Publicip: "1.1.1.1",
Privateport: "30000",
Publicport: "80",
Cidrlist: defaultAllowedCIDR,
Algorithm: "roundrobin",
Protocol: LoadBalancerProtocolTCP.CSProtocol(),
}
if edit != nil {
edit(lbRule)
}
return lbRule
}
unrestricted := &corev1.Service{}
restrictedTo := func(cidrs string) *corev1.Service {
return &corev1.Service{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: cidrs,
}}}
}
tests := []struct {
name string
existing *cloudstack.LoadBalancerRule
ruleName string
protocol LoadBalancerProtocol
service *corev1.Service
version semver.Version
want ruleChange
}{
{
name: "no existing rule is created",
service: unrestricted,
version: withCIDRUpdate,
want: ruleMissing,
},
{
name: "matching rule is left alone",
existing: existingRule(nil),
service: unrestricted,
version: withCIDRUpdate,
want: ruleUpToDate,
},
{
name: "rule on another public IP is recreated",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Publicip = "2.2.2.2" }),
service: unrestricted,
version: withCIDRUpdate,
want: ruleNeedsRecreate,
},
{
name: "changed node port is recreated",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Privateport = "30001" }),
service: unrestricted,
version: withCIDRUpdate,
want: ruleNeedsRecreate,
},
{
name: "changed algorithm is updated",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Algorithm = "source" }),
service: unrestricted,
version: withCIDRUpdate,
want: ruleNeedsUpdate,
},
{
name: "protocol change is updated in place",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Name = "rule-tcp-80" }),
ruleName: "rule-tcp-proxy-80",
protocol: LoadBalancerProtocolTCPProxy,
service: unrestricted,
version: withCIDRUpdate,
want: ruleNeedsUpdate,
},
{
name: "cidr change is updated where the API accepts a cidrlist",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Cidrlist = "10.0.0.0/8" }),
service: restrictedTo("10.0.0.0/8,192.168.0.0/16"),
version: withCIDRUpdate,
want: ruleNeedsUpdate,
},
// CloudStack moves from 4.x to 24.0 after 4.23, so the 4.22 feature gate has
// to keep treating the new numbering as newer rather than older.
{
name: "cidr change is updated on the 24.0 series",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Cidrlist = "10.0.0.0/8" }),
service: restrictedTo("10.0.0.0/8,192.168.0.0/16"),
version: semver.MustParse("24.0.0"),
want: ruleNeedsUpdate,
},
{
name: "cidr change is recreated below the cidrlist update release",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Cidrlist = "10.0.0.0/8" }),
service: restrictedTo("10.0.0.0/8,192.168.0.0/16"),
version: belowCIDRUpdate,
want: ruleNeedsRecreate,
},
{
name: "matching multi-CIDR list is left alone",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Cidrlist = "10.0.0.0/8,192.168.0.0/16" }),
service: restrictedTo("10.0.0.0/8,192.168.0.0/16"),
version: withCIDRUpdate,
want: ruleUpToDate,
},
{
name: "rule with no cidrlist matches an unrestricted service",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Cidrlist = "" }),
service: unrestricted,
version: belowCIDRUpdate,
want: ruleUpToDate,
},
{
name: "rule with no cidrlist is recreated for a restricted service below the update release",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Cidrlist = "" }),
service: restrictedTo("10.0.0.0/8"),
version: belowCIDRUpdate,
want: ruleNeedsRecreate,
},
{
name: "rule with no cidrlist is updated for a restricted service",
existing: existingRule(func(r *cloudstack.LoadBalancerRule) { r.Cidrlist = "" }),
service: restrictedTo("10.0.0.0/8"),
version: withCIDRUpdate,
want: ruleNeedsUpdate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
// No expectations: deciding a change must not call CloudStack.
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{LoadBalancer: cloudstack.NewMockLoadBalancerServiceIface(ctrl)},
ipAddr: "1.1.1.1",
algorithm: "roundrobin",
}
ruleName := tt.ruleName
if ruleName == "" {
ruleName = "rule"
}
got, err := lb.checkLoadBalancerRule(tt.existing, ruleName, port, tt.protocol, tt.service, tt.version)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("checkLoadBalancerRule = %v, want %v", got, tt.want)
}
})
}
t.Run("invalid cidr returns error", func(t *testing.T) {
lb := &loadBalancer{ipAddr: "1.1.1.1", algorithm: "roundrobin"}
if _, err := lb.checkLoadBalancerRule(existingRule(nil), "rule", port, LoadBalancerProtocolTCP, restrictedTo("bad-cidr"), withCIDRUpdate); err == nil {
t.Fatalf("expected error for invalid CIDR")
}
})
}
func TestSplitCIDRList(t *testing.T) {
tests := []struct {
name string
cidrList string
want []string
}{
{name: "empty", cidrList: "", want: nil},
{name: "single", cidrList: "10.0.0.0/8", want: []string{"10.0.0.0/8"}},
{
name: "comma separated",
cidrList: "10.0.0.0/8,192.168.0.0/16",
want: []string{"10.0.0.0/8", "192.168.0.0/16"},
},
{
name: "space separated",
cidrList: "10.0.0.0/8 192.168.0.0/16",
want: []string{"10.0.0.0/8", "192.168.0.0/16"},
},
{
name: "comma and surrounding spaces",
cidrList: "10.0.0.0/8, 192.168.0.0/16",
want: []string{"10.0.0.0/8", "192.168.0.0/16"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitCIDRList(tt.cidrList)
if len(got) != len(tt.want) {
t.Fatalf("splitCIDRList(%q) = %v, want %v", tt.cidrList, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("splitCIDRList(%q)[%d] = %q, want %q", tt.cidrList, i, got[i], tt.want[i])
}
}
})
}
}
func TestFindLoadBalancerRule(t *testing.T) {
port80 := corev1.ServicePort{Port: 80, NodePort: 30000, Protocol: corev1.ProtocolTCP}
// newLB builds a load balancer reconciling towards ip-1, holding the given rules.
newLB := func(rules ...*cloudstack.LoadBalancerRule) *loadBalancer {
lb := &loadBalancer{
ipAddr: "10.0.0.1",
ipAddrID: "ip-1",
rules: map[string]*cloudstack.LoadBalancerRule{},
}
for _, r := range rules {
lb.rules[r.Name] = r
}
return lb
}
rule := func(name, protocol, publicPort string) *cloudstack.LoadBalancerRule {
return &cloudstack.LoadBalancerRule{
Name: name, Protocol: protocol, Publicport: publicPort,
Publicip: "10.0.0.1", Publicipid: "ip-1",
}
}
t.Run("exact name match", func(t *testing.T) {
tcpRule := rule("lb-tcp-80", "tcp", "80")
lb := newLB(tcpRule)
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != tcpRule {
t.Fatalf("findLoadBalancerRule = %v, want exact match %v", got, tcpRule)
}
})
t.Run("protocol toggle falls back to IP protocol and port", func(t *testing.T) {
tcpRule := rule("lb-tcp-80", "tcp", "80")
lb := newLB(tcpRule)
if got := lb.findLoadBalancerRule("lb-tcp-proxy-80", port80, LoadBalancerProtocolTCPProxy); got != tcpRule {
t.Fatalf("findLoadBalancerRule = %v, want fallback match %v", got, tcpRule)
}
})
t.Run("reverse protocol toggle", func(t *testing.T) {
proxyRule := rule("lb-tcp-proxy-80", "tcp-proxy", "80")
lb := newLB(proxyRule)
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != proxyRule {
t.Fatalf("findLoadBalancerRule = %v, want fallback match %v", got, proxyRule)
}
})
t.Run("udp rule does not match tcp port", func(t *testing.T) {
lb := newLB(rule("lb-udp-80", "udp", "80"))
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != nil {
t.Fatalf("findLoadBalancerRule = %v, want nil (udp must not satisfy tcp)", got)
}
})
t.Run("tcp and udp on the same port stay distinct", func(t *testing.T) {
tcpRule := rule("lb-tcp-8000", "tcp", "8000")
udpRule := rule("lb-udp-8000", "udp", "8000")
lb := newLB(tcpRule, udpRule)
port := corev1.ServicePort{Port: 8000, NodePort: 30800, Protocol: corev1.ProtocolUDP}
if got := lb.findLoadBalancerRule("lb-udp-8000", port, LoadBalancerProtocolUDP); got != udpRule {
t.Fatalf("findLoadBalancerRule = %v, want %v", got, udpRule)
}
// A proxy-protocol toggle on the tcp port must resolve to the tcp rule, never the udp one.
port.Protocol = corev1.ProtocolTCP
if got := lb.findLoadBalancerRule("lb-tcp-proxy-8000", port, LoadBalancerProtocolTCPProxy); got != tcpRule {
t.Fatalf("findLoadBalancerRule = %v, want %v", got, tcpRule)
}
})
t.Run("port mismatch returns nil", func(t *testing.T) {
lb := newLB(rule("lb-tcp-443", "tcp", "443"))
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != nil {
t.Fatalf("findLoadBalancerRule = %v, want nil", got)
}
})
t.Run("rule on another IP is not reused", func(t *testing.T) {
// Reusing a rule on a stale IP would delete it via checkLoadBalancerRule, stranding
// its firewall rule. It must be left for the prune pass instead.
staleName := rule("lb-tcp-80", "tcp", "80")
staleName.Publicip, staleName.Publicipid = "10.0.0.2", "ip-2"
staleFallback := rule("lb-tcp-proxy-80", "tcp-proxy", "80")
staleFallback.Publicip, staleFallback.Publicipid = "10.0.0.2", "ip-2"
lb := newLB(staleName, staleFallback)
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != nil {
t.Fatalf("findLoadBalancerRule = %v, want nil for a rule on another IP", got)
}
})
t.Run("current IP preferred over exact name on another IP", func(t *testing.T) {
staleName := rule("lb-tcp-80", "tcp", "80")
staleName.Publicip, staleName.Publicipid = "10.0.0.2", "ip-2"
current := rule("lb-tcp-proxy-80", "tcp-proxy", "80")
lb := newLB(staleName, current)
// The exact name lives on the stale IP; the fallback must find the current-IP rule.
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != current {
t.Fatalf("findLoadBalancerRule = %v, want current-IP rule %v", got, current)
}
})
t.Run("multiple candidates picked deterministically", func(t *testing.T) {
ruleA := rule("lb-tcp-80-a", "tcp", "80")
ruleB := rule("lb-tcp-80-b", "tcp-proxy", "80")
lb := newLB(ruleA, ruleB)
// Both share (tcp, 80); the pick must follow name order, not map order.
for i := 0; i < 10; i++ {
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != ruleA {
t.Fatalf("findLoadBalancerRule = %v, want deterministic first-by-name %v", got, ruleA)
}
}
})
t.Run("empty rules map returns nil", func(t *testing.T) {
lb := newLB()
if got := lb.findLoadBalancerRule("lb-tcp-80", port80, LoadBalancerProtocolTCP); got != nil {
t.Fatalf("findLoadBalancerRule = %v, want nil", got)
}
})
}
func TestRuleToString(t *testing.T) {
tests := []struct {
name string
rule *cloudstack.FirewallRule
want string
}{
{
name: "TCP rule",
rule: &cloudstack.FirewallRule{
Protocol: "tcp",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.1",
Startport: 80,
Endport: 80,
},
want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}",
},
{
name: "UDP rule",
rule: &cloudstack.FirewallRule{
Protocol: "udp",
Cidrlist: "192.168.0.0/16",
Ipaddress: "203.0.113.2",
Startport: 53,
Endport: 53,
},
want: "{[192.168.0.0/16] -> 203.0.113.2:[53-53] (udp)}",
},
{
name: "TCP rule with port range",
rule: &cloudstack.FirewallRule{
Protocol: "tcp",
Cidrlist: "0.0.0.0/0",
Ipaddress: "203.0.113.3",
Startport: 8000,
Endport: 8999,
},
want: "{[0.0.0.0/0] -> 203.0.113.3:[8000-8999] (tcp)}",
},
{
name: "ICMP rule",
rule: &cloudstack.FirewallRule{
Protocol: "icmp",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.4",
Icmptype: 8,
Icmpcode: 0,
},
want: "{[10.0.0.0/8] -> 203.0.113.4 [8,0] (icmp)}",
},
{
name: "unknown protocol",
rule: &cloudstack.FirewallRule{
Protocol: "gre",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.6",
},
want: "{[10.0.0.0/8] -> 203.0.113.6 (gre)}",
},
{
name: "nil rule",
rule: nil,
want: "nil",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ruleToString(tt.rule)
if got != tt.want {
t.Errorf("ruleToString() = %q, want %q", got, tt.want)
}
})
}
}
func TestRulesToString(t *testing.T) {
tests := []struct {
name string
rules []*cloudstack.FirewallRule
want string
}{
{
name: "empty list",
rules: []*cloudstack.FirewallRule{},
want: "",
},
{
name: "single rule",
rules: []*cloudstack.FirewallRule{
{
Protocol: "tcp",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.1",
Startport: 80,
Endport: 80,
},
},
want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}",
},
{
name: "multiple rules",
rules: []*cloudstack.FirewallRule{
{
Protocol: "tcp",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.1",
Startport: 80,
Endport: 80,
},
{
Protocol: "udp",
Cidrlist: "192.168.0.0/16",
Ipaddress: "203.0.113.2",
Startport: 53,
Endport: 53,
},
{
Protocol: "icmp",
Cidrlist: "0.0.0.0/0",
Ipaddress: "203.0.113.3",
Icmptype: 8,
Icmpcode: 0,
},
},
want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}, {[192.168.0.0/16] -> 203.0.113.2:[53-53] (udp)}, {[0.0.0.0/0] -> 203.0.113.3 [8,0] (icmp)}",
},
{
name: "rules with nil rule",
rules: []*cloudstack.FirewallRule{
{
Protocol: "tcp",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.1",
Startport: 80,
Endport: 80,
},
nil,
{
Protocol: "udp",
Cidrlist: "192.168.0.0/16",
Ipaddress: "203.0.113.2",
Startport: 53,
Endport: 53,
},
},
want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}, nil, {[192.168.0.0/16] -> 203.0.113.2:[53-53] (udp)}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rulesToString(tt.rules)
if got != tt.want {
t.Errorf("rulesToString() = %q, want %q", got, tt.want)
}
})
}
}
func TestRulesMapToString(t *testing.T) {
tests := []struct {
name string
rules map[*cloudstack.FirewallRule]bool
want string
}{
{
name: "empty map",
rules: map[*cloudstack.FirewallRule]bool{},
want: "",
},
{
name: "single rule",
rules: map[*cloudstack.FirewallRule]bool{
{
Protocol: "tcp",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.1",
Startport: 80,
Endport: 80,
}: true,
},
want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}",
},
{
name: "multiple rules",
rules: map[*cloudstack.FirewallRule]bool{
{
Protocol: "tcp",
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.1",
Startport: 80,
Endport: 80,
}: true,
{
Protocol: "udp",
Cidrlist: "192.168.0.0/16",
Ipaddress: "203.0.113.2",
Startport: 53,
Endport: 53,
}: false,
{
Protocol: "icmp",
Cidrlist: "0.0.0.0/0",
Ipaddress: "203.0.113.3",
Icmptype: 8,
Icmpcode: 0,
}: true,
},
// Note: Map iteration order is non-deterministic, so we need to check
// that all rules are present, not the exact order
want: "", // We'll check this differently
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rulesMapToString(tt.rules)
if tt.want == "" {
// For maps, we can't predict order, so check that all rules are present
if len(tt.rules) == 0 {
if got != "" {
t.Errorf("rulesMapToString() = %q, want empty string", got)
}
return
}
// Check that all rules are present in the output
expectedRules := make([]string, 0, len(tt.rules))
for rule := range tt.rules {
expectedRules = append(expectedRules, ruleToString(rule))
}
// Split the output and check each rule is present
if got != "" {
parts := strings.Split(got, ", ")
if len(parts) != len(expectedRules) {
t.Errorf("rulesMapToString() returned %d rules, want %d", len(parts), len(expectedRules))
return
}
// Check that all expected rules are in the output
for _, expectedRule := range expectedRules {
found := false
for _, part := range parts {
if part == expectedRule {
found = true
break
}
}
if !found {
t.Errorf("rulesMapToString() missing rule %q in output %q", expectedRule, got)
}
}
} else if len(expectedRules) > 0 {
t.Errorf("rulesMapToString() = empty string, want rules to be present")
}
} else {
if got != tt.want {
t.Errorf("rulesMapToString() = %q, want %q", got, tt.want)
}
}
})
}
}
func TestGetPublicIPAddress(t *testing.T) {
t.Run("IP found and allocated", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
resp := &cloudstack.ListPublicIpAddressesResponse{
Count: 1,
PublicIpAddresses: []*cloudstack.PublicIpAddress{
{
Id: "ip-123",
Ipaddress: "203.0.113.1",
Allocated: "2023-01-01T00:00:00+0000",
},
},
}
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
}
err := lb.getPublicIPAddress("203.0.113.1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
})
t.Run("IP found but not allocated - associates", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
resp := &cloudstack.ListPublicIpAddressesResponse{
Count: 1,
PublicIpAddresses: []*cloudstack.PublicIpAddress{
{
Id: "ip-123",
Ipaddress: "203.0.113.1",
Allocated: "",
},
},
}
networkResp := &cloudstack.Network{
Id: "net-123",
Vpcid: "",
Service: []cloudstack.NetworkServiceInternal{},
}
associateParams := &cloudstack.AssociateIpAddressParams{}
associateResp := &cloudstack.AssociateIpAddressResponse{
Id: "ip-123",
Ipaddress: "203.0.113.1",
}
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil),
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams),
mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
networkID: "net-123",
ipAddr: "203.0.113.1",
}
err := lb.getPublicIPAddress("203.0.113.1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
})
t.Run("IP not found", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
resp := &cloudstack.ListPublicIpAddressesResponse{
Count: 0,
PublicIpAddresses: []*cloudstack.PublicIpAddress{},
}
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
}
err := lb.getPublicIPAddress("203.0.113.1")
if err == nil {
t.Fatalf("expected error for IP not found")
}
if !strings.Contains(err.Error(), "could not find IP address") {
t.Errorf("error message = %q, want to contain 'could not find IP address'", err.Error())
}
})
t.Run("multiple IPs found", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
resp := &cloudstack.ListPublicIpAddressesResponse{
Count: 2,
PublicIpAddresses: []*cloudstack.PublicIpAddress{
{Id: "ip-1", Ipaddress: "203.0.113.1"},
{Id: "ip-2", Ipaddress: "203.0.113.1"},
},
}
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
}
err := lb.getPublicIPAddress("203.0.113.1")
if err == nil {
t.Fatalf("expected error for multiple IPs found")
}
if !strings.Contains(err.Error(), "Found 2 addresses") {
t.Errorf("error message = %q, want to contain 'Found 2 addresses'", err.Error())
}
})
t.Run("error retrieving IP", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
apiErr := fmt.Errorf("API error")
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
}
err := lb.getPublicIPAddress("203.0.113.1")
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error retrieving IP address") {
t.Errorf("error message = %q, want to contain 'error retrieving IP address'", err.Error())
}
})
t.Run("project ID handling", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
resp := &cloudstack.ListPublicIpAddressesResponse{
Count: 1,
PublicIpAddresses: []*cloudstack.PublicIpAddress{
{
Id: "ip-123",
Ipaddress: "203.0.113.1",
Allocated: "2023-01-01T00:00:00+0000",
},
},
}
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
projectID: "proj-123",
}
err := lb.getPublicIPAddress("203.0.113.1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
})
}
func TestAssociatePublicIPAddress(t *testing.T) {
t.Run("associate IP for regular network", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Vpcid: "",
Service: []cloudstack.NetworkServiceInternal{},
}
associateParams := &cloudstack.AssociateIpAddressParams{}
associateResp := &cloudstack.AssociateIpAddressResponse{
Id: "ip-123",
Ipaddress: "203.0.113.1",
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams),
mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
networkID: "net-123",
}
err := lb.associatePublicIPAddress()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
if !lb.ipAssociatedByController {
t.Errorf("ipAssociatedByController = false, want true")
}
})
t.Run("associate IP for VPC network", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Vpcid: "vpc-456",
Service: []cloudstack.NetworkServiceInternal{},
}
associateParams := &cloudstack.AssociateIpAddressParams{}
associateResp := &cloudstack.AssociateIpAddressResponse{
Id: "ip-123",
Ipaddress: "203.0.113.1",
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams),
mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
networkID: "net-123",
}
err := lb.associatePublicIPAddress()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
if !lb.ipAssociatedByController {
t.Errorf("ipAssociatedByController = false, want true")
}
})
t.Run("error retrieving network", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
apiErr := fmt.Errorf("network API error")
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(nil, 1, apiErr)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
},
networkID: "net-123",
}
err := lb.associatePublicIPAddress()
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error retrieving network") {
t.Errorf("error message = %q, want to contain 'error retrieving network'", err.Error())
}
})
t.Run("network not found", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(nil, 0, fmt.Errorf("not found"))
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
},
networkID: "net-123",
}
err := lb.associatePublicIPAddress()
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "could not find network") {
t.Errorf("error message = %q, want to contain 'could not find network'", err.Error())
}
})
t.Run("error associating IP", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Vpcid: "",
Service: []cloudstack.NetworkServiceInternal{},
}
associateParams := &cloudstack.AssociateIpAddressParams{}
apiErr := fmt.Errorf("associate API error")
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams),
mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
networkID: "net-123",
}
err := lb.associatePublicIPAddress()
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error associating new IP address") {
t.Errorf("error message = %q, want to contain 'error associating new IP address'", err.Error())
}
})
t.Run("project ID handling", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Vpcid: "",
Service: []cloudstack.NetworkServiceInternal{},
}
associateParams := &cloudstack.AssociateIpAddressParams{}
associateResp := &cloudstack.AssociateIpAddressResponse{
Id: "ip-123",
Ipaddress: "203.0.113.1",
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams),
mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
networkID: "net-123",
projectID: "proj-123",
}
err := lb.associatePublicIPAddress()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestReleaseLoadBalancerIP(t *testing.T) {
t.Run("successful release", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
disassociateParams := &cloudstack.DisassociateIpAddressParams{}
gomock.InOrder(
mockAddress.EXPECT().NewDisassociateIpAddressParams("ip-123").Return(disassociateParams),
mockAddress.EXPECT().DisassociateIpAddress(disassociateParams).Return(&cloudstack.DisassociateIpAddressResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
ipAddrID: "ip-123",
ipAddr: "203.0.113.1",
}
err := lb.releaseLoadBalancerIP()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("error releasing IP", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
disassociateParams := &cloudstack.DisassociateIpAddressParams{}
apiErr := fmt.Errorf("disassociate API error")
gomock.InOrder(
mockAddress.EXPECT().NewDisassociateIpAddressParams("ip-123").Return(disassociateParams),
mockAddress.EXPECT().DisassociateIpAddress(disassociateParams).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
ipAddrID: "ip-123",
ipAddr: "203.0.113.1",
}
err := lb.releaseLoadBalancerIP()
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error releasing load balancer IP") {
t.Errorf("error message = %q, want to contain 'error releasing load balancer IP'", err.Error())
}
})
}
func TestGetLoadBalancerIP(t *testing.T) {
t.Run("IP specified - retrieve existing", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
resp := &cloudstack.ListPublicIpAddressesResponse{
Count: 1,
PublicIpAddresses: []*cloudstack.PublicIpAddress{
{
Id: "ip-123",
Ipaddress: "203.0.113.1",
Allocated: "2023-01-01T00:00:00+0000",
},
},
}
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
},
}
err := lb.getLoadBalancerIP("203.0.113.1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
})
t.Run("IP specified - associate unallocated", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
listParams := &cloudstack.ListPublicIpAddressesParams{}
resp := &cloudstack.ListPublicIpAddressesResponse{
Count: 1,
PublicIpAddresses: []*cloudstack.PublicIpAddress{
{
Id: "ip-123",
Ipaddress: "203.0.113.1",
Allocated: "",
},
},
}
networkResp := &cloudstack.Network{
Id: "net-123",
Vpcid: "",
Service: []cloudstack.NetworkServiceInternal{},
}
associateParams := &cloudstack.AssociateIpAddressParams{}
associateResp := &cloudstack.AssociateIpAddressResponse{
Id: "ip-123",
Ipaddress: "203.0.113.1",
}
gomock.InOrder(
mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams),
mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil),
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams),
mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
networkID: "net-123",
ipAddr: "203.0.113.1",
}
err := lb.getLoadBalancerIP("203.0.113.1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
if !lb.ipAssociatedByController {
t.Errorf("ipAssociatedByController = false, want true")
}
})
t.Run("no IP specified - associate new", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Vpcid: "",
Service: []cloudstack.NetworkServiceInternal{},
}
associateParams := &cloudstack.AssociateIpAddressParams{}
associateResp := &cloudstack.AssociateIpAddressResponse{
Id: "ip-123",
Ipaddress: "203.0.113.1",
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams),
mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
networkID: "net-123",
}
err := lb.getLoadBalancerIP("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
if !lb.ipAssociatedByController {
t.Errorf("ipAssociatedByController = false, want true")
}
})
}
func TestCreateLoadBalancerRule(t *testing.T) {
t.Run("create rule with default CIDR", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
createParams := &cloudstack.CreateLoadBalancerRuleParams{}
createResp := &cloudstack.CreateLoadBalancerRuleResponse{
Id: "rule-123",
Algorithm: "roundrobin",
Cidrlist: defaultAllowedCIDR,
Name: "test-rule-tcp-80",
Networkid: "net-123",
Privateport: "30000",
Publicport: "80",
Publicip: "203.0.113.1",
Publicipid: "ip-123",
Protocol: "tcp",
}
gomock.InOrder(
mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams),
mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
networkID: "net-123",
ipAddrID: "ip-123",
ipAddr: "203.0.113.1",
}
port := corev1.ServicePort{
Port: 80,
NodePort: 30000,
Protocol: corev1.ProtocolTCP,
}
service := &corev1.Service{}
rule, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rule.Id != "rule-123" {
t.Errorf("rule.Id = %q, want %q", rule.Id, "rule-123")
}
if rule.Name != "test-rule-tcp-80" {
t.Errorf("rule.Name = %q, want %q", rule.Name, "test-rule-tcp-80")
}
})
t.Run("create rule with custom CIDR list", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
createParams := &cloudstack.CreateLoadBalancerRuleParams{}
createResp := &cloudstack.CreateLoadBalancerRuleResponse{
Id: "rule-123",
Algorithm: "roundrobin",
Cidrlist: "10.0.0.0/8,192.168.0.0/16",
Name: "test-rule-tcp-80",
Networkid: "net-123",
Privateport: "30000",
Publicport: "80",
Publicip: "203.0.113.1",
Publicipid: "ip-123",
Protocol: "tcp",
}
gomock.InOrder(
mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams),
mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
networkID: "net-123",
ipAddrID: "ip-123",
ipAddr: "203.0.113.1",
}
port := corev1.ServicePort{
Port: 80,
NodePort: 30000,
Protocol: corev1.ProtocolTCP,
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: "10.0.0.0/8,192.168.0.0/16",
},
},
}
rule, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rule.Cidrlist != "10.0.0.0/8,192.168.0.0/16" {
t.Errorf("rule.Cidrlist = %q, want %q", rule.Cidrlist, "10.0.0.0/8,192.168.0.0/16")
}
})
t.Run("create rule with proxy protocol", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
createParams := &cloudstack.CreateLoadBalancerRuleParams{}
createResp := &cloudstack.CreateLoadBalancerRuleResponse{
Id: "rule-123",
Algorithm: "roundrobin",
Cidrlist: defaultAllowedCIDR,
Name: "test-rule-tcp-proxy-80",
Networkid: "net-123",
Privateport: "30000",
Publicport: "80",
Publicip: "203.0.113.1",
Publicipid: "ip-123",
Protocol: "tcp-proxy",
}
gomock.InOrder(
mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-proxy-80", 30000, 80).Return(createParams),
mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
networkID: "net-123",
ipAddrID: "ip-123",
ipAddr: "203.0.113.1",
}
port := corev1.ServicePort{
Port: 80,
NodePort: 30000,
Protocol: corev1.ProtocolTCP,
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
ServiceAnnotationLoadBalancerProxyProtocol: "true",
},
},
}
rule, err := lb.createLoadBalancerRule("test-rule-tcp-proxy-80", port, LoadBalancerProtocolTCPProxy, service)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rule.Protocol != "tcp-proxy" {
t.Errorf("rule.Protocol = %q, want %q", rule.Protocol, "tcp-proxy")
}
})
t.Run("error creating rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
createParams := &cloudstack.CreateLoadBalancerRuleParams{}
apiErr := fmt.Errorf("create rule API error")
gomock.InOrder(
mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams),
mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
networkID: "net-123",
ipAddrID: "ip-123",
ipAddr: "203.0.113.1",
}
port := corev1.ServicePort{
Port: 80,
NodePort: 30000,
Protocol: corev1.ProtocolTCP,
}
service := &corev1.Service{}
_, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error creating load balancer rule") {
t.Errorf("error message = %q, want to contain 'error creating load balancer rule'", err.Error())
}
})
t.Run("invalid CIDR in annotation", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
createParams := &cloudstack.CreateLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
}
port := corev1.ServicePort{
Port: 80,
NodePort: 30000,
Protocol: corev1.ProtocolTCP,
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: "invalid-cidr",
},
},
}
_, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service)
if err == nil {
t.Fatalf("expected error for invalid CIDR")
}
if !strings.Contains(err.Error(), "invalid CIDR") {
t.Errorf("error message = %q, want to contain 'invalid CIDR'", err.Error())
}
})
}
func TestUpdateLoadBalancerRule(t *testing.T) {
t.Run("update algorithm", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams),
mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "source",
rules: map[string]*cloudstack.LoadBalancerRule{
"test-rule-tcp-80": {
Id: "rule-123",
Algorithm: "roundrobin",
Protocol: "tcp",
},
},
}
service := &corev1.Service{}
err := lb.updateLoadBalancerRule(lb.rules["test-rule-tcp-80"], "test-rule-tcp-80", LoadBalancerProtocolTCP, service, semver.Version{Major: 4, Minor: 22, Patch: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if algo, _ := updateParams.GetAlgorithm(); algo != "source" {
t.Errorf("algorithm = %q, want %q", algo, "source")
}
})
t.Run("update protocol", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams),
mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
rules: map[string]*cloudstack.LoadBalancerRule{
"test-rule-tcp-80": {
Id: "rule-123",
Algorithm: "roundrobin",
Protocol: "tcp",
},
},
}
service := &corev1.Service{}
// Matched under the old name, so the update must switch protocol and rename.
err := lb.updateLoadBalancerRule(lb.rules["test-rule-tcp-80"], "test-rule-tcp-proxy-80", LoadBalancerProtocolTCPProxy, service, semver.Version{Major: 4, Minor: 22, Patch: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if proto, _ := updateParams.GetProtocol(); proto != "tcp-proxy" {
t.Errorf("protocol = %q, want %q", proto, "tcp-proxy")
}
if name, _ := updateParams.GetName(); name != "test-rule-tcp-proxy-80" {
t.Errorf("name = %q, want %q", name, "test-rule-tcp-proxy-80")
}
})
t.Run("update CIDR list (CS >= 4.22)", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams),
mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
rules: map[string]*cloudstack.LoadBalancerRule{
"test-rule-tcp-80": {
Id: "rule-123",
Algorithm: "roundrobin",
Protocol: "tcp",
Cidrlist: defaultAllowedCIDR,
},
},
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: "10.0.0.0/8",
},
},
}
err := lb.updateLoadBalancerRule(lb.rules["test-rule-tcp-80"], "test-rule-tcp-80", LoadBalancerProtocolTCP, service, semver.Version{Major: 4, Minor: 22, Patch: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cidrs, _ := updateParams.GetCidrlist(); len(cidrs) != 1 || cidrs[0] != "10.0.0.0/8" {
t.Errorf("cidrlist = %v, want %v", cidrs, []string{"10.0.0.0/8"})
}
})
// The release after 4.23 is numbered 24.0, which must still reach the
// in-place CIDR update rather than falling back to delete-and-recreate.
t.Run("update CIDR list on the 24.0 series", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams),
mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
rules: map[string]*cloudstack.LoadBalancerRule{
"test-rule-tcp-80": {
Id: "rule-123",
Algorithm: "roundrobin",
Protocol: "tcp",
Cidrlist: defaultAllowedCIDR,
},
},
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
ServiceAnnotationLoadBalancerSourceCidrs: "10.0.0.0/8",
},
},
}
if err := lb.updateLoadBalancerRule(lb.rules["test-rule-tcp-80"], "test-rule-tcp-80", LoadBalancerProtocolTCP, service, semver.MustParse("24.0.0")); err != nil {
t.Fatalf("unexpected error: %v", err)
}
cidrList, ok := updateParams.GetCidrlist()
if !ok {
t.Fatalf("expected the CIDR list to be set on the update params")
}
if len(cidrList) != 1 || cidrList[0] != "10.0.0.0/8" {
t.Fatalf("cidrlist = %v, want [10.0.0.0/8]", cidrList)
}
})
t.Run("error updating rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
apiErr := fmt.Errorf("update rule API error")
gomock.InOrder(
mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams),
mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
algorithm: "roundrobin",
rules: map[string]*cloudstack.LoadBalancerRule{
"test-rule-tcp-80": {
Id: "rule-123",
Algorithm: "roundrobin",
Protocol: "tcp",
},
},
}
service := &corev1.Service{}
err := lb.updateLoadBalancerRule(lb.rules["test-rule-tcp-80"], "test-rule-tcp-80", LoadBalancerProtocolTCP, service, semver.Version{Major: 4, Minor: 22, Patch: 0})
if err == nil {
t.Fatalf("expected error")
}
if err != apiErr {
t.Errorf("error = %v, want %v", err, apiErr)
}
})
}
func TestDeleteLoadBalancerRule(t *testing.T) {
t.Run("successful deletion", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
deleteParams := &cloudstack.DeleteLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-123").Return(deleteParams),
mockLB.EXPECT().DeleteLoadBalancerRule(deleteParams).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
rules: map[string]*cloudstack.LoadBalancerRule{
"test-rule": {
Id: "rule-123",
Name: "test-rule",
},
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.deleteLoadBalancerRule(rule)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, exists := lb.rules["test-rule"]; exists {
t.Errorf("expected rule to be removed from map")
}
})
t.Run("error deleting rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
deleteParams := &cloudstack.DeleteLoadBalancerRuleParams{}
apiErr := fmt.Errorf("delete rule API error")
gomock.InOrder(
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-123").Return(deleteParams),
mockLB.EXPECT().DeleteLoadBalancerRule(deleteParams).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
rules: map[string]*cloudstack.LoadBalancerRule{
"test-rule": {
Id: "rule-123",
Name: "test-rule",
},
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.deleteLoadBalancerRule(rule)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error deleting load balancer rule") {
t.Errorf("error message = %q, want to contain 'error deleting load balancer rule'", err.Error())
}
})
}
func TestAssignHostsToRule(t *testing.T) {
t.Run("successful assignment", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
assignParams := &cloudstack.AssignToLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewAssignToLoadBalancerRuleParams("rule-123").Return(assignParams),
mockLB.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.assignHostsToRule(rule, []string{"vm-1", "vm-2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("error assigning hosts", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
assignParams := &cloudstack.AssignToLoadBalancerRuleParams{}
apiErr := fmt.Errorf("assign API error")
gomock.InOrder(
mockLB.EXPECT().NewAssignToLoadBalancerRuleParams("rule-123").Return(assignParams),
mockLB.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.assignHostsToRule(rule, []string{"vm-1"})
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error assigning hosts") {
t.Errorf("error message = %q, want to contain 'error assigning hosts'", err.Error())
}
})
t.Run("empty host list", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
assignParams := &cloudstack.AssignToLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewAssignToLoadBalancerRuleParams("rule-123").Return(assignParams),
mockLB.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.assignHostsToRule(rule, []string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestRemoveHostsFromRule(t *testing.T) {
t.Run("successful removal", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
removeParams := &cloudstack.RemoveFromLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-123").Return(removeParams),
mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).Return(&cloudstack.RemoveFromLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.removeHostsFromRule(rule, []string{"vm-1", "vm-2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("error removing hosts", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
removeParams := &cloudstack.RemoveFromLoadBalancerRuleParams{}
apiErr := fmt.Errorf("remove API error")
gomock.InOrder(
mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-123").Return(removeParams),
mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.removeHostsFromRule(rule, []string{"vm-1"})
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error removing hosts") {
t.Errorf("error message = %q, want to contain 'error removing hosts'", err.Error())
}
})
t.Run("empty host list", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
removeParams := &cloudstack.RemoveFromLoadBalancerRuleParams{}
gomock.InOrder(
mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-123").Return(removeParams),
mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).Return(&cloudstack.RemoveFromLoadBalancerRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
rule := &cloudstack.LoadBalancerRule{
Id: "rule-123",
Name: "test-rule",
}
err := lb.removeHostsFromRule(rule, []string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestUpdateFirewallRule(t *testing.T) {
t.Run("create new firewall rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 0,
FirewallRules: []*cloudstack.FirewallRule{},
}
createParams := &cloudstack.CreateFirewallRuleParams{}
createResp := &cloudstack.CreateFirewallRuleResponse{
Id: "fw-123",
}
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams),
mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
ipAddr: "203.0.113.1",
}
updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("rule already exists - no change", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{
{
Id: "fw-123",
Protocol: "tcp",
Startport: 80,
Endport: 80,
Cidrlist: "10.0.0.0/8",
Ipaddress: "203.0.113.1",
Ipaddressid: "ip-123",
},
},
}
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
ipAddr: "203.0.113.1",
}
updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("update existing rule - CIDR change", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{
{
Id: "fw-123",
Protocol: "tcp",
Startport: 80,
Endport: 80,
Cidrlist: "192.168.0.0/16",
Ipaddress: "203.0.113.1",
Ipaddressid: "ip-123",
},
},
}
deleteParams := &cloudstack.DeleteFirewallRuleParams{}
createParams := &cloudstack.CreateFirewallRuleParams{}
createResp := &cloudstack.CreateFirewallRuleResponse{
Id: "fw-124",
}
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams),
mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil),
mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams),
mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
ipAddr: "203.0.113.1",
}
updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("default CIDR when empty list", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 0,
FirewallRules: []*cloudstack.FirewallRule{},
}
createParams := &cloudstack.CreateFirewallRuleParams{}
createResp := &cloudstack.CreateFirewallRuleResponse{
Id: "fw-123",
}
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams),
mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
ipAddr: "203.0.113.1",
}
updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("error listing rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
apiErr := fmt.Errorf("list API error")
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
ipAddr: "203.0.113.1",
}
_, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"})
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error fetching firewall rules") {
t.Errorf("error message = %q, want to contain 'error fetching firewall rules'", err.Error())
}
})
t.Run("error creating rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 0,
FirewallRules: []*cloudstack.FirewallRule{},
}
createParams := &cloudstack.CreateFirewallRuleParams{}
apiErr := fmt.Errorf("create API error")
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams),
mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
ipAddr: "203.0.113.1",
}
_, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"})
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error creating new firewall rule") {
t.Errorf("error message = %q, want to contain 'error creating new firewall rule'", err.Error())
}
})
t.Run("error deleting rule - continues", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{
{
Id: "fw-123",
Protocol: "tcp",
Startport: 80,
Endport: 80,
Cidrlist: "192.168.0.0/16",
Ipaddress: "203.0.113.1",
Ipaddressid: "ip-123",
},
},
}
deleteParams := &cloudstack.DeleteFirewallRuleParams{}
deleteErr := fmt.Errorf("delete API error")
createParams := &cloudstack.CreateFirewallRuleParams{}
createResp := &cloudstack.CreateFirewallRuleResponse{
Id: "fw-124",
}
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams),
mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(nil, deleteErr),
mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams),
mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
ipAddr: "203.0.113.1",
}
updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"})
// Should still return true even if delete failed
if err != nil && !strings.Contains(err.Error(), "error creating") {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
}
func TestListFirewallRulesDeduplicates(t *testing.T) {
// Each page decodes into its own structs, so a rule returned on two pages
// arrives as two pointers to an equal rule. updateFirewallRule keys its
// bookkeeping on the pointer: it would keep one copy as the CIDR match and
// delete the other by ID, removing the very rule it had just matched and
// creating nothing in its place.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
rule := func() *cloudstack.FirewallRule {
return &cloudstack.FirewallRule{
Id: "fw-keep", Protocol: "tcp", Startport: 80, Endport: 80,
Cidrlist: defaultAllowedCIDR,
}
}
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
mockFirewall.EXPECT().NewListFirewallRulesParams().
Return(&cloudstack.ListFirewallRulesParams{})
// Count of 2 with one rule per page makes listAll page, and the same rule
// comes back both times. It must be built per call: a real second page is
// decoded into its own struct, so the repeat is a distinct pointer.
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Times(2).
DoAndReturn(func(p *cloudstack.ListFirewallRulesParams) (*cloudstack.ListFirewallRulesResponse, error) {
return &cloudstack.ListFirewallRulesResponse{
Count: 2,
FirewallRules: []*cloudstack.FirewallRule{rule()},
}, nil
})
var deleted []string
mockFirewall.EXPECT().NewDeleteFirewallRuleParams(gomock.Any()).AnyTimes().
DoAndReturn(func(id string) *cloudstack.DeleteFirewallRuleParams {
deleted = append(deleted, id)
return &cloudstack.DeleteFirewallRuleParams{}
})
mockFirewall.EXPECT().DeleteFirewallRule(gomock.Any()).AnyTimes().
Return(&cloudstack.DeleteFirewallRuleResponse{}, nil)
created := 0
mockFirewall.EXPECT().NewCreateFirewallRuleParams(gomock.Any(), gomock.Any()).AnyTimes().
DoAndReturn(func(ip, proto string) *cloudstack.CreateFirewallRuleParams {
created++
return &cloudstack.CreateFirewallRuleParams{}
})
mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).AnyTimes().
Return(&cloudstack.CreateFirewallRuleResponse{}, nil)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{Firewall: mockFirewall},
ipAddr: "203.0.113.1",
}
// The existing rule already allows exactly what is wanted, so nothing should
// be deleted and nothing created.
if _, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{defaultAllowedCIDR}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(deleted) > 0 {
t.Errorf("deleted %v, but that rule already matched the wanted CIDR", deleted)
}
if created > 0 {
t.Errorf("created %d replacement rules, want 0", created)
}
}
func TestDeleteFirewallRule(t *testing.T) {
t.Run("delete matching rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{
{
Id: "fw-123",
Protocol: "tcp",
Startport: 80,
Endport: 80,
Ipaddressid: "ip-123",
},
},
}
deleteParams := &cloudstack.DeleteFirewallRuleParams{}
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams),
mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
}
deleted, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !deleted {
t.Errorf("deleted = false, want true")
}
})
t.Run("no matching rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 0,
FirewallRules: []*cloudstack.FirewallRule{},
}
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
}
deleted, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if deleted {
t.Errorf("deleted = true, want false")
}
})
t.Run("error listing rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
apiErr := fmt.Errorf("list API error")
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
}
_, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error fetching firewall rules") {
t.Errorf("error message = %q, want to contain 'error fetching firewall rules'", err.Error())
}
})
t.Run("error deleting rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
listParams := &cloudstack.ListFirewallRulesParams{}
listResp := &cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{
{
Id: "fw-123",
Protocol: "tcp",
Startport: 80,
Endport: 80,
Ipaddressid: "ip-123",
},
},
}
deleteParams := &cloudstack.DeleteFirewallRuleParams{}
deleteErr := fmt.Errorf("delete API error")
gomock.InOrder(
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil),
mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams),
mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(nil, deleteErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Firewall: mockFirewall,
},
}
deleted, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP)
// Should return false if deletion failed
if deleted {
t.Errorf("deleted = true, want false")
}
if err != deleteErr {
t.Errorf("error = %v, want %v", err, deleteErr)
}
})
}
func TestUpdateNetworkACL(t *testing.T) {
t.Run("create new ACL rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
aclListResp := &cloudstack.NetworkACLList{
Id: "acl-456",
Name: "custom-acl",
}
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 0,
NetworkACLs: []*cloudstack.NetworkACL{},
}
createParams := &cloudstack.CreateNetworkACLParams{}
createResp := &cloudstack.CreateNetworkACLResponse{
Id: "acl-rule-123",
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(aclListResp, 1, nil),
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
mockNetworkACL.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams),
mockNetworkACL.EXPECT().CreateNetworkACL(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("tcp-proxy creates ACL rule with tcp protocol", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
aclListResp := &cloudstack.NetworkACLList{
Id: "acl-456",
Name: "custom-acl",
}
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 0,
NetworkACLs: []*cloudstack.NetworkACL{},
}
createParams := &cloudstack.CreateNetworkACLParams{}
createResp := &cloudstack.CreateNetworkACLResponse{
Id: "acl-rule-123",
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(aclListResp, 1, nil),
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
// tcp-proxy must be created as tcp.
mockNetworkACL.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams),
mockNetworkACL.EXPECT().CreateNetworkACL(gomock.Any()).Return(createResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCPProxy, "net-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("tcp-proxy matches existing tcp ACL rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
aclListResp := &cloudstack.NetworkACLList{
Id: "acl-456",
Name: "custom-acl",
}
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
{
Id: "acl-rule-123",
Protocol: "tcp",
Startport: "80",
Endport: "80",
},
},
}
// No create expectations: the tcp rule already satisfies the tcp-proxy port.
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(aclListResp, 1, nil),
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCPProxy, "net-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("rule already exists", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
aclListResp := &cloudstack.NetworkACLList{
Id: "acl-456",
Name: "custom-acl",
}
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
{
Id: "acl-rule-123",
Protocol: "tcp",
Startport: "80",
Endport: "80",
},
},
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(aclListResp, 1, nil),
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("default ACL - skip", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
aclListResp := &cloudstack.NetworkACLList{
Id: "acl-456",
Name: "default_allow",
}
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(aclListResp, 1, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Errorf("updated = false, want true")
}
})
t.Run("error fetching network", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
apiErr := fmt.Errorf("network API error")
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(nil, 1, apiErr)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
},
}
_, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123")
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error fetching Network") {
t.Errorf("error message = %q, want to contain 'error fetching Network'", err.Error())
}
})
t.Run("error fetching ACL list", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
apiErr := fmt.Errorf("ACL list API error")
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(nil, 0, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
_, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123")
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error fetching Network ACL List") {
t.Errorf("error message = %q, want to contain 'error fetching Network ACL List'", err.Error())
}
})
t.Run("network not found", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
aclListResp := &cloudstack.NetworkACLList{
Id: "acl-456",
Name: "custom-acl",
}
listParams := &cloudstack.ListNetworkACLsParams{}
apiErr := fmt.Errorf("list ACL API error")
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(aclListResp, 1, nil),
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
_, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123")
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error fetching Network ACL") {
t.Errorf("error message = %q, want to contain 'error fetching Network ACL'", err.Error())
}
})
t.Run("error creating ACL rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
networkResp := &cloudstack.Network{
Id: "net-123",
Aclid: "acl-456",
Service: []cloudstack.NetworkServiceInternal{},
}
aclListResp := &cloudstack.NetworkACLList{
Id: "acl-456",
Name: "custom-acl",
}
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 0,
NetworkACLs: []*cloudstack.NetworkACL{},
}
createParams := &cloudstack.CreateNetworkACLParams{}
apiErr := fmt.Errorf("create ACL API error")
gomock.InOrder(
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", gomock.Any()).Return(aclListResp, 1, nil),
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
mockNetworkACL.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams),
mockNetworkACL.EXPECT().CreateNetworkACL(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
},
}
_, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123")
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error creating Network ACL") {
t.Errorf("error message = %q, want to contain 'error creating Network ACL'", err.Error())
}
})
}
func TestDeleteNetworkACLRule(t *testing.T) {
t.Run("delete matching rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
{
Id: "acl-rule-123",
Protocol: "tcp",
Startport: "80",
Endport: "80",
},
},
}
deleteParams := &cloudstack.DeleteNetworkACLParams{}
gomock.InOrder(
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
mockNetworkACL.EXPECT().NewDeleteNetworkACLParams("acl-rule-123").Return(deleteParams),
mockNetworkACL.EXPECT().DeleteNetworkACL(deleteParams).Return(&cloudstack.DeleteNetworkACLResponse{}, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
NetworkACL: mockNetworkACL,
},
}
deleted, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !deleted {
t.Errorf("deleted = false, want true")
}
})
t.Run("no matching rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 0,
NetworkACLs: []*cloudstack.NetworkACL{},
}
gomock.InOrder(
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
NetworkACL: mockNetworkACL,
},
}
deleted, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !deleted {
t.Errorf("deleted = false, want true")
}
})
t.Run("error listing ACLs", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
listParams := &cloudstack.ListNetworkACLsParams{}
apiErr := fmt.Errorf("list ACL API error")
gomock.InOrder(
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(nil, apiErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
NetworkACL: mockNetworkACL,
},
}
_, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123")
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error fetching Network ACL rules") {
t.Errorf("error message = %q, want to contain 'error fetching Network ACL rules'", err.Error())
}
})
t.Run("error deleting ACL", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
listParams := &cloudstack.ListNetworkACLsParams{}
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
{
Id: "acl-rule-123",
Protocol: "tcp",
Startport: "80",
Endport: "80",
},
},
}
deleteParams := &cloudstack.DeleteNetworkACLParams{}
deleteErr := fmt.Errorf("delete ACL API error")
gomock.InOrder(
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
mockNetworkACL.EXPECT().NewDeleteNetworkACLParams("acl-rule-123").Return(deleteParams),
mockNetworkACL.EXPECT().DeleteNetworkACL(deleteParams).Return(nil, deleteErr),
)
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
NetworkACL: mockNetworkACL,
},
}
deleted, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123")
if deleted {
t.Errorf("deleted = true, want false")
}
if err != deleteErr {
t.Errorf("error = %v, want %v", err, deleteErr)
}
})
}
func TestGetLoadBalancer(t *testing.T) {
t.Run("load balancer with existing rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
listParams := &cloudstack.ListLoadBalancerRulesParams{}
listResp := &cloudstack.ListLoadBalancerRulesResponse{
Count: 2,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{
{
Id: "rule-1",
Name: "test-service-tcp-80",
Publicip: "203.0.113.1",
Publicipid: "ip-123",
Algorithm: "roundrobin",
Protocol: "tcp",
Publicport: "80",
Privateport: "30000",
},
{
Id: "rule-2",
Name: "test-service-tcp-443",
Publicip: "203.0.113.1",
Publicipid: "ip-123",
Algorithm: "roundrobin",
Protocol: "tcp",
Publicport: "443",
Privateport: "30443",
},
},
}
gomock.InOrder(
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(listParams),
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(listResp, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
}
lb, err := cs.getLoadBalancer(service)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lb.ipAddr != "203.0.113.1" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1")
}
if lb.ipAddrID != "ip-123" {
t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123")
}
if len(lb.rules) != 2 {
t.Errorf("rules count = %d, want %d", len(lb.rules), 2)
}
})
t.Run("load balancer with no rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
listParams := &cloudstack.ListLoadBalancerRulesParams{}
listResp := &cloudstack.ListLoadBalancerRulesResponse{
Count: 0,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{},
}
gomock.InOrder(
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(listParams),
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(listResp, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
}
lb, err := cs.getLoadBalancer(service)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(lb.rules) != 0 {
t.Errorf("rules count = %d, want %d", len(lb.rules), 0)
}
if lb.ipAddr != "" {
t.Errorf("ipAddr = %q, want empty", lb.ipAddr)
}
})
t.Run("error retrieving rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
listParams := &cloudstack.ListLoadBalancerRulesParams{}
apiErr := fmt.Errorf("list rules API error")
gomock.InOrder(
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(listParams),
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(nil, apiErr),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
},
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
}
_, err := cs.getLoadBalancer(service)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "error retrieving load balancer rules") {
t.Errorf("error message = %q, want to contain 'error retrieving load balancer rules'", err.Error())
}
})
listDuplicates := func(t *testing.T, requestedIP, publishedIP string, rules ...*cloudstack.LoadBalancerRule) *loadBalancer {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).
Return(&cloudstack.ListLoadBalancerRulesResponse{Count: len(rules), LoadBalancerRules: rules}, nil)
cs := &CSCloud{client: &cloudstack.CloudStackClient{LoadBalancer: mockLB}}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "test-service", Namespace: "default"},
Spec: corev1.ServiceSpec{LoadBalancerIP: requestedIP},
}
if publishedIP != "" {
service.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{IP: publishedIP}}
}
lb, err := cs.getLoadBalancer(service)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return lb
}
onAutoIP := &cloudstack.LoadBalancerRule{Id: "rule-auto", Name: "test-service-tcp-80", Publicip: "203.0.113.1", Publicipid: "ip-auto"}
onRequestedIP := &cloudstack.LoadBalancerRule{Id: "rule-requested", Name: "test-service-tcp-80", Publicip: "203.0.113.9", Publicipid: "ip-requested"}
assertKept := func(t *testing.T, lb *loadBalancer, kept, discarded *cloudstack.LoadBalancerRule) {
if got := lb.rules["test-service-tcp-80"]; got != kept {
t.Errorf("kept rule = %v, want %v", got.Id, kept.Id)
}
if len(lb.duplicateRules) != 1 || lb.duplicateRules[0] != discarded {
t.Errorf("duplicateRules = %+v, want only %v", lb.duplicateRules, discarded.Id)
}
if lb.ipAddr != kept.Publicip || lb.ipAddrID != kept.Publicipid {
t.Errorf("ipAddr/ipAddrID = %v/%v, want the kept rule's %v/%v", lb.ipAddr, lb.ipAddrID, kept.Publicip, kept.Publicipid)
}
}
t.Run("the first of two same-named rules is kept", func(t *testing.T) {
lb := listDuplicates(t, "", "", onAutoIP, onRequestedIP)
assertKept(t, lb, onAutoIP, onRequestedIP)
})
t.Run("a later rule on the requested IP is kept over an earlier one", func(t *testing.T) {
lb := listDuplicates(t, onRequestedIP.Publicip, "", onAutoIP, onRequestedIP)
assertKept(t, lb, onRequestedIP, onAutoIP)
})
t.Run("an earlier rule on the requested IP stays kept", func(t *testing.T) {
lb := listDuplicates(t, onRequestedIP.Publicip, "", onRequestedIP, onAutoIP)
assertKept(t, lb, onRequestedIP, onAutoIP)
})
t.Run("the rule on the published ingress IP is kept when no IP was requested", func(t *testing.T) {
lb := listDuplicates(t, "", onRequestedIP.Publicip, onAutoIP, onRequestedIP)
assertKept(t, lb, onRequestedIP, onAutoIP)
})
t.Run("a requested IP outranks the published ingress IP", func(t *testing.T) {
lb := listDuplicates(t, onAutoIP.Publicip, onRequestedIP.Publicip, onRequestedIP, onAutoIP)
assertKept(t, lb, onAutoIP, onRequestedIP)
})
// Rules with distinct names are not duplicates, so what settles the IP the service is
// reconciled towards is the preferred address, and without one the first-listed rule,
// matching the duplicate sweep.
onPort80 := &cloudstack.LoadBalancerRule{Id: "rule-80", Name: "test-service-tcp-80", Publicip: "203.0.113.9", Publicipid: "ip-requested"}
onPort443 := &cloudstack.LoadBalancerRule{Id: "rule-443", Name: "test-service-tcp-443", Publicip: "203.0.113.1", Publicipid: "ip-auto"}
assertResolvedIP := func(t *testing.T, lb *loadBalancer, wantIP, wantIPID string) {
t.Helper()
if lb.ipAddr != wantIP || lb.ipAddrID != wantIPID {
t.Errorf("ipAddr/ipAddrID = %v/%v, want %v/%v", lb.ipAddr, lb.ipAddrID, wantIP, wantIPID)
}
if len(lb.rules) != 2 {
t.Errorf("rules count = %d, want 2", len(lb.rules))
}
}
t.Run("the requested IP outranks a stale IP listed after it", func(t *testing.T) {
lb := listDuplicates(t, onPort80.Publicip, "", onPort80, onPort443)
assertResolvedIP(t, lb, onPort80.Publicip, onPort80.Publicipid)
})
t.Run("the published ingress IP outranks a stale IP listed after it", func(t *testing.T) {
lb := listDuplicates(t, "", onPort80.Publicip, onPort80, onPort443)
assertResolvedIP(t, lb, onPort80.Publicip, onPort80.Publicipid)
})
t.Run("the requested IP outranks a stale IP listed before it", func(t *testing.T) {
lb := listDuplicates(t, onPort80.Publicip, "", onPort443, onPort80)
assertResolvedIP(t, lb, onPort80.Publicip, onPort80.Publicipid)
})
t.Run("the first-listed IP wins when none is preferred", func(t *testing.T) {
lb := listDuplicates(t, "", "", onPort443, onPort80)
assertResolvedIP(t, lb, onPort443.Publicip, onPort443.Publicipid)
})
t.Run("a preferred IP no rule is on leaves the first-listed IP", func(t *testing.T) {
lb := listDuplicates(t, "198.51.100.7", "", onPort443, onPort80)
assertResolvedIP(t, lb, onPort443.Publicip, onPort443.Publicipid)
})
}
func TestGetLoadBalancerDeduplicatesPagedRules(t *testing.T) {
// A rule returned on two pages arrives as two pointers to an equal rule.
// getLoadBalancer would see the name already in lb.rules, call it a
// duplicate, and hand it to the sweep in EnsureLoadBalancer - deleting the
// only rule the Service is published on. The IP preference cannot save it,
// because both copies carry the same address.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
mockLB.EXPECT().NewListLoadBalancerRulesParams().
Return(&cloudstack.ListLoadBalancerRulesParams{})
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Times(2).
DoAndReturn(func(p *cloudstack.ListLoadBalancerRulesParams) (*cloudstack.ListLoadBalancerRulesResponse, error) {
return &cloudstack.ListLoadBalancerRulesResponse{
Count: 2,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{
{Id: "rule-1", Name: "a-svc-TCP-80", Publicip: "1.2.3.4", Publicipid: "ip-1"},
},
}, nil
})
cs := &CSCloud{client: &cloudstack.CloudStackClient{LoadBalancer: mockLB}}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default", UID: "abc123"},
}
lb, err := cs.getLoadBalancer(service)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(lb.duplicateRules) != 0 {
t.Errorf("rule %v was repeated across pages, not duplicated; it must not be swept", lb.duplicateRules)
}
if len(lb.rules) != 1 {
t.Errorf("rules = %d, want 1", len(lb.rules))
}
if lb.ipAddr != "1.2.3.4" {
t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "1.2.3.4")
}
}
// A failed sweep is reported so the service controller retries, but only after
// this service's own rules and IP are gone, so the retry sees the leftover
// duplicate as an ordinary rule instead of blocking deletion for ever.
func TestEnsureLoadBalancerDeletedReportsASweepFailureLast(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
mockFW := cloudstack.NewMockFirewallServiceIface(ctrl)
mockAddr := cloudstack.NewMockAddressServiceIface(ctrl)
mockNet := cloudstack.NewMockNetworkServiceIface(ctrl)
kept := &cloudstack.LoadBalancerRule{Id: "keep", Name: "test-service-tcp-80", Publicip: "203.0.113.1", Publicipid: "ip-keep", Publicport: "80", Protocol: "tcp"}
duplicate := &cloudstack.LoadBalancerRule{Id: "dup", Name: "test-service-tcp-80", Publicip: "203.0.113.2", Publicipid: "ip-dup", Publicport: "80", Protocol: "tcp"}
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 2, LoadBalancerRules: []*cloudstack.LoadBalancerRule{kept, duplicate},
}, nil)
// The sweep fails while listing the duplicate's firewall rules.
mockFW.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{})
mockFW.EXPECT().ListFirewallRules(gomock.Any()).Return(nil, fmt.Errorf("firewall API down"))
// Deletion of the kept rule must still happen.
mockAddr.EXPECT().GetPublicIpAddressByID("ip-keep", gomock.Any()).
Return(&cloudstack.PublicIpAddress{Id: "ip-keep", Associatednetworkid: "net-1"}, 1, nil)
// Once inside getNetworkIDFromIPAddress, once in the delete loop itself.
mockNet.EXPECT().GetNetworkByID("net-1", gomock.Any()).
Return(&cloudstack.Network{Id: "net-1"}, 1, nil).Times(2)
mockFW.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{})
mockFW.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{}, nil)
deleteParams := &cloudstack.DeleteLoadBalancerRuleParams{}
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("keep").Return(deleteParams)
mockLB.EXPECT().DeleteLoadBalancerRule(deleteParams).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil)
// And the load balancer IP must still be released.
release := &cloudstack.DisassociateIpAddressParams{}
mockAddr.EXPECT().NewDisassociateIpAddressParams("ip-keep").Return(release)
mockAddr.EXPECT().DisassociateIpAddress(release).Return(&cloudstack.DisassociateIpAddressResponse{}, nil)
cs := &CSCloud{client: &cloudstack.CloudStackClient{
LoadBalancer: mockLB, Firewall: mockFW, Address: mockAddr, Network: mockNet,
}}
service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "test-service", Namespace: "default"}}
err := cs.EnsureLoadBalancerDeleted(context.TODO(), "test", service)
if err == nil {
t.Fatal("expected the sweep failure to be reported so the delete is retried")
}
if !strings.Contains(err.Error(), "firewall API down") {
t.Errorf("error = %q, want it to carry the sweep failure", err)
}
// gomock asserts on Cleanup that the kept rule was deleted and the IP released
// despite the failure; without that the retry would have nothing to converge on.
}
func TestGetNetworkIDFromIPAddress(t *testing.T) {
t.Run("successful retrieval", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
ipResp := &cloudstack.PublicIpAddress{
Id: "ip-123",
Ipaddress: "203.0.113.1",
Networkid: "net-123",
Associatednetworkid: "net-123",
}
networkResp := &cloudstack.Network{
Id: "net-123",
Service: []cloudstack.NetworkServiceInternal{},
}
gomock.InOrder(
mockAddress.EXPECT().GetPublicIpAddressByID("ip-123", gomock.Any()).Return(ipResp, 1, nil),
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
}
networkID, err := cs.getNetworkIDFromIPAddress("ip-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if networkID != "net-123" {
t.Errorf("networkID = %q, want %q", networkID, "net-123")
}
})
t.Run("IP not found", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
apiErr := fmt.Errorf("IP not found")
mockAddress.EXPECT().GetPublicIpAddressByID("ip-123", gomock.Any()).Return(nil, 0, apiErr)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
Address: mockAddress,
},
}
_, err := cs.getNetworkIDFromIPAddress("ip-123")
if err == nil {
t.Fatalf("expected error")
}
if err != apiErr {
t.Errorf("error = %v, want %v", err, apiErr)
}
})
// The following two cases used to return ("", nil). The caller passes the
// result straight to GetNetworkByID, which does not reject an empty ID, so
// a nil error there resolves to an arbitrary network instead of failing.
t.Run("IP not associated with a network", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockAddress.EXPECT().GetPublicIpAddressByID("ip-123", gomock.Any()).
Return(&cloudstack.PublicIpAddress{Id: "ip-123"}, 1, nil)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{Address: mockAddress},
}
networkID, err := cs.getNetworkIDFromIPAddress("ip-123")
if err == nil {
t.Fatalf("expected an error for an IP with no associated network")
}
if networkID != "" {
t.Errorf("networkID = %q, want empty", networkID)
}
})
t.Run("network lookup fails", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
netErr := fmt.Errorf("network not found")
gomock.InOrder(
mockAddress.EXPECT().GetPublicIpAddressByID("ip-123", gomock.Any()).
Return(&cloudstack.PublicIpAddress{Id: "ip-123", Associatednetworkid: "net-123"}, 1, nil),
mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(nil, 0, netErr),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
Address: mockAddress,
Network: mockNetwork,
},
}
networkID, err := cs.getNetworkIDFromIPAddress("ip-123")
if err != netErr {
t.Errorf("error = %v, want %v", err, netErr)
}
if networkID != "" {
t.Errorf("networkID = %q, want empty", networkID)
}
})
}
// CloudStack does not enforce unique load balancer rule names. Because
// loadBalancer.rules is keyed by name it can only manage one rule per name, so
// the rest are tracked separately and removed; before that they survived
// deletion and leaked with no service left to reference them.
// A duplicate always sits on a different public IP from the kept rule, because
// CloudStack rejects a second rule on the same IP and port. Removing only the
// load balancer rule would therefore leak the duplicate's firewall rule and
// its public IP.
func TestDuplicateLoadBalancerRules(t *testing.T) {
type mocks struct {
lb *cloudstack.MockLoadBalancerServiceIface
fw *cloudstack.MockFirewallServiceIface
addr *cloudstack.MockAddressServiceIface
}
newLB := func(t *testing.T, duplicate *cloudstack.LoadBalancerRule) (*loadBalancer, mocks) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
m := mocks{
lb: cloudstack.NewMockLoadBalancerServiceIface(ctrl),
fw: cloudstack.NewMockFirewallServiceIface(ctrl),
addr: cloudstack.NewMockAddressServiceIface(ctrl),
}
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: m.lb, Firewall: m.fw, Address: m.addr,
},
name: "a-lb",
ipAddrID: "ip-keep",
rules: map[string]*cloudstack.LoadBalancerRule{
"a-lb-tcp-80": {Id: "keep", Name: "a-lb-tcp-80", Publicipid: "ip-keep"},
},
duplicateRules: []*cloudstack.LoadBalancerRule{duplicate},
}
return lb, m
}
onOwnIP := &cloudstack.LoadBalancerRule{
Id: "dup-1", Name: "a-lb-tcp-80", Publicport: "80", Protocol: "tcp", Publicipid: "ip-dup",
}
expectFirewallRules := func(m mocks, rules ...*cloudstack.FirewallRule) {
params := &cloudstack.ListFirewallRulesParams{}
m.fw.EXPECT().NewListFirewallRulesParams().Return(params)
m.fw.EXPECT().ListFirewallRules(params).
Return(&cloudstack.ListFirewallRulesResponse{FirewallRules: rules}, nil)
}
expectRuleDeleted := func(m mocks, id string, err error) {
params := &cloudstack.DeleteLoadBalancerRuleParams{}
m.lb.EXPECT().NewDeleteLoadBalancerRuleParams(id).Return(params)
m.lb.EXPECT().DeleteLoadBalancerRule(params).
Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, err)
}
expectRulesOnIP := func(m mocks, count int) {
params := &cloudstack.ListLoadBalancerRulesParams{}
m.lb.EXPECT().NewListLoadBalancerRulesParams().Return(params)
m.lb.EXPECT().ListLoadBalancerRules(params).
Return(&cloudstack.ListLoadBalancerRulesResponse{Count: count}, nil)
}
t.Run("a duplicate on its own IP takes its firewall rule and IP with it", func(t *testing.T) {
lb, m := newLB(t, onOwnIP)
expectFirewallRules(m, &cloudstack.FirewallRule{Id: "fw-dup", Protocol: "tcp", Startport: 80, Endport: 80})
fwDelete := &cloudstack.DeleteFirewallRuleParams{}
m.fw.EXPECT().NewDeleteFirewallRuleParams("fw-dup").Return(fwDelete)
m.fw.EXPECT().DeleteFirewallRule(fwDelete).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil)
expectRuleDeleted(m, "dup-1", nil)
expectRulesOnIP(m, 0)
release := &cloudstack.DisassociateIpAddressParams{}
m.addr.EXPECT().NewDisassociateIpAddressParams("ip-dup").Return(release)
m.addr.EXPECT().DisassociateIpAddress(release).Return(&cloudstack.DisassociateIpAddressResponse{}, nil)
if err := lb.deleteDuplicateRules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(lb.duplicateRules) != 0 {
t.Errorf("duplicateRules = %d, want 0", len(lb.duplicateRules))
}
if kept := lb.rules["a-lb-tcp-80"]; kept == nil || kept.Id != "keep" {
t.Errorf("kept rule = %+v, want the original rule to survive", kept)
}
})
t.Run("the IP stays while another rule still uses it", func(t *testing.T) {
lb, m := newLB(t, onOwnIP)
expectFirewallRules(m)
expectRuleDeleted(m, "dup-1", nil)
expectRulesOnIP(m, 1)
if err := lb.deleteDuplicateRules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("a duplicate sharing the kept IP never releases it", func(t *testing.T) {
sharesIP := &cloudstack.LoadBalancerRule{
Id: "dup-1", Name: "a-lb-tcp-80", Publicport: "80", Protocol: "tcp", Publicipid: "ip-keep",
}
lb, m := newLB(t, sharesIP)
expectFirewallRules(m)
expectRuleDeleted(m, "dup-1", nil)
if err := lb.deleteDuplicateRules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("a delete failure is surfaced", func(t *testing.T) {
lb, m := newLB(t, onOwnIP)
expectFirewallRules(m)
expectRuleDeleted(m, "dup-1", fmt.Errorf("boom"))
if err := lb.deleteDuplicateRules(); err == nil {
t.Fatal("expected an error when deleting a duplicate fails")
}
})
t.Run("a duplicate with an unparseable public port is left in place without API calls", func(t *testing.T) {
lb, _ := newLB(t, &cloudstack.LoadBalancerRule{Id: "dup-1", Name: "a-lb-tcp-80", Protocol: "tcp"})
if err := lb.deleteDuplicateRules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("a duplicate with an unsupported protocol is left in place without API calls", func(t *testing.T) {
lb, _ := newLB(t, &cloudstack.LoadBalancerRule{Id: "dup-1", Name: "a-lb-tcp-80", Publicport: "80", Protocol: "sctp"})
if err := lb.deleteDuplicateRules(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestVerifyHosts(t *testing.T) {
t.Run("all hosts in same network", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
listParams := &cloudstack.ListVirtualMachinesParams{}
listResp := &cloudstack.ListVirtualMachinesResponse{
Count: 2,
VirtualMachines: []*cloudstack.VirtualMachine{
{
Id: "vm-1",
Name: "node-1",
Nic: []cloudstack.Nic{
{Networkid: "net-123"},
},
},
{
Id: "vm-2",
Name: "node-2",
Nic: []cloudstack.Nic{
{Networkid: "net-123"},
},
},
},
}
gomock.InOrder(
mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams),
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
VirtualMachine: mockVM,
},
}
nodes := []*corev1.Node{
{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}},
{ObjectMeta: metav1.ObjectMeta{Name: "node-2"}},
}
hostIDs, networkID, err := cs.verifyHosts(nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(hostIDs) != 2 {
t.Errorf("hostIDs count = %d, want %d", len(hostIDs), 2)
}
if networkID != "net-123" {
t.Errorf("networkID = %q, want %q", networkID, "net-123")
}
})
t.Run("hosts in different networks", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
listParams := &cloudstack.ListVirtualMachinesParams{}
listResp := &cloudstack.ListVirtualMachinesResponse{
Count: 2,
VirtualMachines: []*cloudstack.VirtualMachine{
{
Id: "vm-1",
Name: "node-1",
Nic: []cloudstack.Nic{
{Networkid: "net-123"},
},
},
{
Id: "vm-2",
Name: "node-2",
Nic: []cloudstack.Nic{
{Networkid: "net-456"},
},
},
},
}
gomock.InOrder(
mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams),
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
VirtualMachine: mockVM,
},
}
nodes := []*corev1.Node{
{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}},
{ObjectMeta: metav1.ObjectMeta{Name: "node-2"}},
}
_, _, err := cs.verifyHosts(nodes)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "different networks") {
t.Errorf("error message = %q, want to contain 'different networks'", err.Error())
}
})
t.Run("no matching hosts", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
listParams := &cloudstack.ListVirtualMachinesParams{}
listResp := &cloudstack.ListVirtualMachinesResponse{
Count: 0,
VirtualMachines: []*cloudstack.VirtualMachine{},
}
gomock.InOrder(
mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams),
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
VirtualMachine: mockVM,
},
}
nodes := []*corev1.Node{
{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}},
}
_, _, err := cs.verifyHosts(nodes)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "none of the hosts matched") {
t.Errorf("error message = %q, want to contain 'none of the hosts matched'", err.Error())
}
})
t.Run("FQDN node names", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
listParams := &cloudstack.ListVirtualMachinesParams{}
listResp := &cloudstack.ListVirtualMachinesResponse{
Count: 1,
VirtualMachines: []*cloudstack.VirtualMachine{
{
Id: "vm-1",
Name: "node-1",
Nic: []cloudstack.Nic{
{Networkid: "net-123"},
},
},
},
}
gomock.InOrder(
mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams),
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
VirtualMachine: mockVM,
},
}
nodes := []*corev1.Node{
{ObjectMeta: metav1.ObjectMeta{Name: "node-1.example.com"}},
}
hostIDs, networkID, err := cs.verifyHosts(nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(hostIDs) != 1 {
t.Errorf("hostIDs count = %d, want %d", len(hostIDs), 1)
}
if networkID != "net-123" {
t.Errorf("networkID = %q, want %q", networkID, "net-123")
}
})
t.Run("case-insensitive matching", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
listParams := &cloudstack.ListVirtualMachinesParams{}
listResp := &cloudstack.ListVirtualMachinesResponse{
Count: 1,
VirtualMachines: []*cloudstack.VirtualMachine{
{
Id: "vm-1",
Name: "NODE-1",
Nic: []cloudstack.Nic{
{Networkid: "net-123"},
},
},
},
}
gomock.InOrder(
mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams),
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil),
)
cs := &CSCloud{
client: &cloudstack.CloudStackClient{
VirtualMachine: mockVM,
},
}
nodes := []*corev1.Node{
{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}},
}
hostIDs, networkID, err := cs.verifyHosts(nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(hostIDs) != 1 {
t.Errorf("hostIDs count = %d, want %d", len(hostIDs), 1)
}
if networkID != "net-123" {
t.Errorf("networkID = %q, want %q", networkID, "net-123")
}
})
}
// pagedRequest is the read side of the paging parameters that every
// cloudstack-go List*Params exposes.
type pagedRequest interface {
GetPage() (int, bool)
GetPagesize() (int, bool)
}
// pageOf returns the window of items a request asks for, mirroring how
// CloudStack serves a list: a request carrying no paging parameters comes back
// truncated at pageSize, and later pages are served by offset. It also asserts
// the paging contract CloudStack enforces.
func pageOf[T any](t *testing.T, p pagedRequest, items []T, pageSize int) []T {
t.Helper()
page, paged := p.GetPage()
size, sized := p.GetPagesize()
if paged != sized {
t.Errorf("page and pagesize must be sent together, got page set = %v, pagesize set = %v", paged, sized)
}
switch {
case !paged:
page, size = 1, pageSize
case page < 2:
t.Errorf("page = %d, want >= 2 (CloudStack rejects page 0)", page)
case size != pageSize:
t.Errorf("pagesize = %d, want %d", size, pageSize)
}
start := (page - 1) * size
if start >= len(items) {
return nil
}
end := start + size
if end > len(items) {
end = len(items)
}
return items[start:end]
}
// nodesNamed builds the node list a cloudprovider call receives.
func nodesNamed(names ...string) []*corev1.Node {
nodes := make([]*corev1.Node, 0, len(names))
for _, name := range names {
nodes = append(nodes, &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name}})
}
return nodes
}
func TestVerifyHostsPagination(t *testing.T) {
// CloudStack truncates list responses at default.page.size while still
// reporting the full total in count. This is the regression from issue #99:
// with more VMs in the account than fit in one page, the nodes beyond the
// first page were invisible and the load balancer was never created.
const pageSize = 500
// Only the last VM is a cluster node, so it lands on the final page.
vms := make([]*cloudstack.VirtualMachine, 750)
for i := range vms {
vms[i] = &cloudstack.VirtualMachine{
Id: fmt.Sprintf("vm-%d", i),
Name: fmt.Sprintf("other-%d", i),
Nic: []cloudstack.Nic{{Networkid: "net-123"}},
}
}
vms[len(vms)-1].Id = "vm-node-1"
vms[len(vms)-1].Name = "node-1"
t.Run("collects hosts from every page", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
// Params are built once and reused across pages.
mockVM.EXPECT().NewListVirtualMachinesParams().
Return(&cloudstack.ListVirtualMachinesParams{})
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Times(2).
DoAndReturn(func(p *cloudstack.ListVirtualMachinesParams) (*cloudstack.ListVirtualMachinesResponse, error) {
return &cloudstack.ListVirtualMachinesResponse{
Count: len(vms),
VirtualMachines: pageOf(t, p, vms, pageSize),
}, nil
})
cs := &CSCloud{client: &cloudstack.CloudStackClient{VirtualMachine: mockVM}}
hostIDs, networkID, err := cs.verifyHosts(nodesNamed("node-1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(hostIDs, []string{"vm-node-1"}) {
t.Errorf("hostIDs = %v, want %v", hostIDs, []string{"vm-node-1"})
}
if networkID != "net-123" {
t.Errorf("networkID = %q, want %q", networkID, "net-123")
}
})
t.Run("deduplicates hosts repeated across pages", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
node := &cloudstack.VirtualMachine{
Id: "vm-node-1",
Name: "node-1",
Nic: []cloudstack.Nic{{Networkid: "net-123"}},
}
// A VM is removed between the requests, shifting the offset so the node
// comes back on both pages.
pages := [][]*cloudstack.VirtualMachine{{node, node}, {node}}
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
mockVM.EXPECT().NewListVirtualMachinesParams().
Return(&cloudstack.ListVirtualMachinesParams{})
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Times(len(pages)).
DoAndReturn(func(p *cloudstack.ListVirtualMachinesParams) (*cloudstack.ListVirtualMachinesResponse, error) {
page := pages[0]
pages = pages[1:]
return &cloudstack.ListVirtualMachinesResponse{Count: 3, VirtualMachines: page}, nil
})
cs := &CSCloud{client: &cloudstack.CloudStackClient{VirtualMachine: mockVM}}
hostIDs, _, err := cs.verifyHosts(nodesNamed("node-1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(hostIDs, []string{"vm-node-1"}) {
t.Errorf("hostIDs = %v, want %v", hostIDs, []string{"vm-node-1"})
}
})
}
// ensureLBTestEnv holds the fixtures shared by the TestEnsureLoadBalancer subtests.
// Each subtest sets its own mock expectations.
type ensureLBTestEnv struct {
cs *CSCloud
lb *cloudstack.MockLoadBalancerServiceIface
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 {
e := &ensureLBTestEnv{
lb: cloudstack.NewMockLoadBalancerServiceIface(ctrl),
vm: cloudstack.NewMockVirtualMachineServiceIface(ctrl),
network: cloudstack.NewMockNetworkServiceIface(ctrl),
firewall: cloudstack.NewMockFirewallServiceIface(ctrl),
address: cloudstack.NewMockAddressServiceIface(ctrl),
}
e.cs = &CSCloud{
client: &cloudstack.CloudStackClient{
LoadBalancer: e.lb,
VirtualMachine: e.vm,
Network: e.network,
Firewall: e.firewall,
Address: e.address,
},
version: semver.Version{Major: 4, Minor: 22, Patch: 0},
}
// UID "test-uid" makes the load balancer name "atestuid", so rules are atestuid-<protocol>-<port>.
e.service = &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
UID: "test-uid",
Annotations: annotations,
},
Spec: corev1.ServiceSpec{
SessionAffinity: corev1.ServiceAffinityNone,
Ports: ports,
},
}
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{})
e.vm.EXPECT().ListVirtualMachines(gomock.Any()).Return(&cloudstack.ListVirtualMachinesResponse{
Count: 1,
VirtualMachines: []*cloudstack.VirtualMachine{
{Id: "vm-1", Name: "node-1", Nic: []cloudstack.Nic{{Networkid: "net-1"}}},
},
}, nil)
}
// expectHostsAndNetwork registers the host and network lookups every run performs.
func (e *ensureLBTestEnv) expectHostsAndNetwork() {
e.expectHostsAndNetworkWith(&cloudstack.Network{
Id: "net-1",
Service: []cloudstack.NetworkServiceInternal{{Name: "Firewall"}},
})
}
// 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) {
tcpPort80 := corev1.ServicePort{Port: 80, NodePort: 30000, Protocol: corev1.ProtocolTCP}
existingTCPRule := func() *cloudstack.LoadBalancerRule {
return &cloudstack.LoadBalancerRule{
Id: "rule-1",
Name: "atestuid-tcp-80",
Publicip: "10.0.0.1",
Publicipid: "ip-1",
Publicport: "80",
Privateport: "30000",
Cidrlist: defaultAllowedCIDR,
Algorithm: "roundrobin",
Protocol: "tcp",
Networkid: "net-1",
}
}
matchingFirewallRule := &cloudstack.FirewallRule{
Id: "fw-1",
Protocol: "tcp",
Startport: 80,
Endport: 80,
Cidrlist: defaultAllowedCIDR,
}
t.Run("proxy protocol toggle updates rule in place", func(t *testing.T) {
// Regression test for issue #2: enabling the annotation on a live service must update
// the existing tcp rule, not create a conflicting tcp-proxy rule on the same port.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, map[string]string{
ServiceAnnotationLoadBalancerProxyProtocol: "true",
}, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
// No create or delete expectations: either call fails the test.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{existingTCPRule()},
}, nil),
env.lb.EXPECT().NewUpdateLoadBalancerRuleParams("rule-1").Return(updateParams),
env.lb.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
// The existing tcp/80 firewall rule also serves tcp-proxy.
gomock.InOrder(
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
status, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(status.Ingress) != 1 || status.Ingress[0].IP != "10.0.0.1" {
t.Errorf("status.Ingress = %v, want IP 10.0.0.1", status.Ingress)
}
if proto, _ := updateParams.GetProtocol(); proto != "tcp-proxy" {
t.Errorf("updated protocol = %q, want %q", proto, "tcp-proxy")
}
if name, _ := updateParams.GetName(); name != "atestuid-tcp-proxy-80" {
t.Errorf("updated name = %q, want %q", name, "atestuid-tcp-proxy-80")
}
})
t.Run("proxy protocol removal updates rule in place", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
existingProxyRule := existingTCPRule()
existingProxyRule.Name = "atestuid-tcp-proxy-80"
existingProxyRule.Protocol = "tcp-proxy"
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{existingProxyRule},
}, nil),
env.lb.EXPECT().NewUpdateLoadBalancerRuleParams("rule-1").Return(updateParams),
env.lb.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
gomock.InOrder(
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
_, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if proto, _ := updateParams.GetProtocol(); proto != "tcp" {
t.Errorf("updated protocol = %q, want %q", proto, "tcp")
}
if name, _ := updateParams.GetName(); name != "atestuid-tcp-80" {
t.Errorf("updated name = %q, want %q", name, "atestuid-tcp-80")
}
})
t.Run("rule named by the old provider is adopted and renamed", func(t *testing.T) {
// Migration from the in-tree provider, whose rule names carried no protocol and whose
// protocol was sent in upper case, which CloudStack before 4.21 stored verbatim. The
// rule must be adopted rather than recreated, so the README can stop asking operators
// to delete their rules before migrating.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
legacyRule := existingTCPRule()
legacyRule.Id = "rule-legacy"
legacyRule.Name = "atestuid-80"
legacyRule.Protocol = "TCP"
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
// No create and no delete expectations: either call fails the test.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{legacyRule},
}, nil),
env.lb.EXPECT().NewUpdateLoadBalancerRuleParams("rule-legacy").Return(updateParams),
env.lb.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
gomock.InOrder(
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name, _ := updateParams.GetName(); name != "atestuid-tcp-80" {
t.Errorf("updated name = %q, want %q", name, "atestuid-tcp-80")
}
if proto, _ := updateParams.GetProtocol(); proto != "tcp" {
t.Errorf("updated protocol = %q, want %q", proto, "tcp")
}
})
t.Run("rule named by the old provider is renamed in place below the cidrlist update release", func(t *testing.T) {
// In-tree rules carry no cidrlist. That allows every source, so on a release that can
// only apply a CIDR change by recreating the rule it must still count as unchanged.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.cs.version = semver.MustParse("4.21.0")
env.expectHostsAndNetwork()
legacyRule := existingTCPRule()
legacyRule.Id = "rule-legacy"
legacyRule.Name = "atestuid-80"
legacyRule.Protocol = "TCP"
legacyRule.Cidrlist = ""
updateParams := &cloudstack.UpdateLoadBalancerRuleParams{}
// No create and no delete expectations: either call fails the test.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{legacyRule},
}, nil),
env.lb.EXPECT().NewUpdateLoadBalancerRuleParams("rule-legacy").Return(updateParams),
env.lb.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil),
)
gomock.InOrder(
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name, _ := updateParams.GetName(); name != "atestuid-tcp-80" {
t.Errorf("updated name = %q, want %q", name, "atestuid-tcp-80")
}
})
t.Run("an unsupported port leaves a rule that needs recreating in place", func(t *testing.T) {
// Port 80 needs recreating for its new nodePort, and the SCTP port after it fails
// the reconcile. That fails every retry, so deleting port 80 before the error would
// leave it without a rule for good.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
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.expectHostsAndNetwork()
// No delete expectation: the existing port 80 rule must survive.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{existingTCPRule()},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err == nil {
t.Fatalf("expected the unsupported SCTP port to fail the reconcile")
}
})
t.Run("a failure before the apply phase leaves a rule that needs recreating in place", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
movedPort80 := corev1.ServicePort{Port: 80, NodePort: 30001, Protocol: corev1.ProtocolTCP}
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{movedPort80})
env.expectHosts()
env.network.EXPECT().GetNetworkByID("net-1", gomock.Any()).Return(nil, -1, fmt.Errorf("API unavailable"))
// No delete expectation: the existing port 80 rule must survive.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{existingTCPRule()},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err == nil {
t.Fatalf("expected the network lookup failure to fail the reconcile")
}
})
t.Run("a rule that needs recreating is replaced in the apply phase", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
movedPort80 := corev1.ServicePort{Port: 80, NodePort: 30001, Protocol: corev1.ProtocolTCP}
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{movedPort80})
env.expectHostsAndNetwork()
// The kept firewall rule shows the recreate is not mistaken for an obsolete rule.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{existingTCPRule()},
}, nil),
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(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("obsolete rule pruned after new rule created", func(t *testing.T) {
// The service moved from port 80 to 443. The new rule is created first, so a failure
// while pruning port 80 can never leave the service with no rule at all.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
tcpPort443 := corev1.ServicePort{Port: 443, NodePort: 30443, Protocol: corev1.ProtocolTCP}
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort443})
env.expectHostsAndNetwork()
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{existingTCPRule()},
}, nil),
// The port 443 rule is created, then its hosts and firewall reconciled.
env.lb.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "atestuid-tcp-443", 30443, 443).Return(&cloudstack.CreateLoadBalancerRuleParams{}),
env.lb.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(&cloudstack.CreateLoadBalancerRuleResponse{
Id: "rule-2",
Name: "atestuid-tcp-443",
Publicip: "10.0.0.1",
Publicipid: "ip-1",
Protocol: "tcp",
}, nil),
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-2").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(&cloudstack.ListFirewallRulesResponse{}, nil),
env.firewall.EXPECT().NewCreateFirewallRuleParams("ip-1", "tcp").Return(&cloudstack.CreateFirewallRuleParams{}),
env.firewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(&cloudstack.CreateFirewallRuleResponse{}, nil),
// Only then is the obsolete port 80 rule pruned: firewall rule, then the LB rule.
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
env.firewall.EXPECT().NewDeleteFirewallRuleParams("fw-1").Return(&cloudstack.DeleteFirewallRuleParams{}),
env.firewall.EXPECT().DeleteFirewallRule(gomock.Any()).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil),
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-1").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil),
)
_, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("duplicate rule on claimed port keeps firewall rules", func(t *testing.T) {
// A leftover tcp rule shares (tcp, 80) with the desired tcp-proxy rule. The duplicate
// is pruned, but its firewall rule is the one the kept rule needs, so it must stay.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, map[string]string{
ServiceAnnotationLoadBalancerProxyProtocol: "true",
}, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
duplicateProxyRule := existingTCPRule()
duplicateProxyRule.Id = "rule-2"
duplicateProxyRule.Name = "atestuid-tcp-proxy-80"
duplicateProxyRule.Protocol = "tcp-proxy"
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 2,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{existingTCPRule(), duplicateProxyRule},
}, nil),
// The obsolete tcp rule is deleted, the tcp-proxy rule is kept as-is.
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-1").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil),
)
// One firewall listing (the apply pass) and no deletions.
gomock.InOrder(
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
_, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("rule blocking a create is pruned before the create", func(t *testing.T) {
// Two rules share (tcp, 80) and the nodePort changed, so the matched rule has to be
// recreated. The other rule still holds public port 80, so it must be deleted before
// the create or CloudStack rejects it with a port conflict.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
movedPort80 := corev1.ServicePort{Port: 80, NodePort: 30001, Protocol: corev1.ProtocolTCP}
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{movedPort80})
env.expectHostsAndNetwork()
matched := existingTCPRule()
matched.Id = "rule-a"
duplicate := existingTCPRule()
duplicate.Id = "rule-b"
duplicate.Name = "atestuid-tcp-proxy-80"
duplicate.Protocol = "tcp-proxy"
// No firewall delete expectation: the tcp/80 opening is still claimed by the desired
// port, so pruning the duplicate must leave it alone.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 2,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{matched, duplicate},
}, nil),
// The blocking rule is pruned first...
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-b").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil),
// ...and the matched rule, which cannot take a new nodePort, is deleted only
// immediately before its replacement is created.
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-a").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(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("udp rule blocks a tcp create on the same port", func(t *testing.T) {
// CloudStack rejects two load balancer rules with overlapping ports on one IP whatever
// their protocols, so an obsolete udp/80 rule must be pruned before the tcp/80 create
// even though the two never match each other. Its udp firewall rule is not claimed by
// the desired tcp port, so that goes too.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
udpRule := existingTCPRule()
udpRule.Id = "rule-udp"
udpRule.Name = "atestuid-udp-80"
udpRule.Protocol = "udp"
udpFirewallRule := &cloudstack.FirewallRule{
Id: "fw-udp", Protocol: "udp", Startport: 80, Endport: 80, Cidrlist: defaultAllowedCIDR,
}
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{udpRule},
}, nil),
// Prune first: the udp firewall rule, then the udp load balancer rule.
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{udpFirewallRule},
}, nil),
env.firewall.EXPECT().NewDeleteFirewallRuleParams("fw-udp").Return(&cloudstack.DeleteFirewallRuleParams{}),
env.firewall.EXPECT().DeleteFirewallRule(gomock.Any()).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil),
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-udp").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil),
// Only then can the tcp rule be created on the freed port.
env.lb.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "atestuid-tcp-80", 30000, 80).Return(&cloudstack.CreateLoadBalancerRuleParams{}),
env.lb.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(&cloudstack.CreateLoadBalancerRuleResponse{
Id: "rule-tcp", Name: "atestuid-tcp-80", Publicip: "10.0.0.1", Publicipid: "ip-1", Protocol: "tcp",
}, nil),
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-tcp").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(&cloudstack.ListFirewallRulesResponse{}, nil),
env.firewall.EXPECT().NewCreateFirewallRuleParams("ip-1", "tcp").Return(&cloudstack.CreateFirewallRuleParams{}),
env.firewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(&cloudstack.CreateFirewallRuleResponse{}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("unparseable leftover rule does not block reconciliation", func(t *testing.T) {
// A rule the provider cannot interpret must be skipped, not abort the whole sync:
// the desired ports still have to be reconciled.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
junkRule := existingTCPRule()
junkRule.Id = "rule-junk"
junkRule.Name = "atestuid-http-8080"
junkRule.Protocol = "http"
junkRule.Publicport = "8080"
// No delete expectation for rule-junk: it is skipped, not deleted.
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 2,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{junkRule, existingTCPRule()},
}, nil),
)
// The desired tcp/80 rule is still reconciled.
gomock.InOrder(
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("unparseable rule holding a needed port is pruned first", func(t *testing.T) {
// A rule the provider cannot interpret still occupies its public port, so one sitting
// on a port a create needs has to go before that create, or CloudStack rejects it.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
junkRule := existingTCPRule()
junkRule.Id = "rule-junk"
junkRule.Name = "atestuid-http-80"
junkRule.Protocol = "http"
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{junkRule},
}, nil),
// No firewall expectations for the junk rule: its protocol cannot be resolved, so
// only the load balancer rule itself is deleted, and before the create.
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-junk").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil),
env.lb.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "atestuid-tcp-80", 30000, 80).Return(&cloudstack.CreateLoadBalancerRuleParams{}),
env.lb.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(&cloudstack.CreateLoadBalancerRuleResponse{
Id: "rule-2",
Name: "atestuid-tcp-80",
Publicip: "10.0.0.1",
Publicipid: "ip-1",
Protocol: "tcp",
}, nil),
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-2").Return(&cloudstack.AssignToLoadBalancerRuleParams{}),
env.lb.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{}, nil),
)
gomock.InOrder(
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
)
if _, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("prune failure still reconciles desired rules", func(t *testing.T) {
// A failed delete is still reported, even though the desired rules were applied fine.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.expectHostsAndNetwork()
obsolete := existingTCPRule()
obsolete.Id = "rule-obsolete"
obsolete.Name = "atestuid-tcp-8080"
obsolete.Publicport = "8080"
deleteErr := fmt.Errorf("delete rule API error")
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 2,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{obsolete, existingTCPRule()},
}, nil),
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-obsolete").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(nil, deleteErr),
)
gomock.InOrder(
// Apply pass: the desired tcp/80 firewall rule already matches.
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
// Prune pass: nothing matches the obsolete port 8080.
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{}, nil),
)
_, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes)
if err == nil {
t.Fatalf("expected the prune failure to be reported")
}
if !strings.Contains(err.Error(), "delete rule API error") {
t.Errorf("error = %v, want it to mention the delete failure", err)
}
})
t.Run("obsolete rule on another IP has firewall rules deleted", func(t *testing.T) {
// An obsolete rule on another public IP shares (tcp, 80) with a desired port. Claims
// are per IP, so the old IP's firewall rule must still be deleted.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, []corev1.ServicePort{tcpPort80})
env.service.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{IP: "10.0.0.1"}}
env.expectHostsAndNetwork()
oldIPRule := existingTCPRule()
oldIPRule.Id = "rule-9"
oldIPRule.Name = "atestuid-tcp-80-old"
oldIPRule.Publicip = "10.0.0.2"
oldIPRule.Publicipid = "ip-2"
oldIPFirewallRule := &cloudstack.FirewallRule{
Id: "fw-2",
Protocol: "tcp",
Startport: 80,
Endport: 80,
Cidrlist: defaultAllowedCIDR,
}
gomock.InOrder(
env.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{}),
// Listed first, yet the published ingress IP makes ip-1 the address reconciled towards.
env.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 2,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{oldIPRule, existingTCPRule()},
}, nil),
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-9").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil),
)
gomock.InOrder(
// Apply pass: the kept rule's firewall rule already matches.
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{matchingFirewallRule},
}, nil),
// Prune pass: the old IP's rule is unclaimed, so it is deleted.
env.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
env.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{
Count: 1,
FirewallRules: []*cloudstack.FirewallRule{oldIPFirewallRule},
}, nil),
env.firewall.EXPECT().NewDeleteFirewallRuleParams("fw-2").Return(&cloudstack.DeleteFirewallRuleParams{}),
env.firewall.EXPECT().DeleteFirewallRule(gomock.Any()).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil),
)
_, err := env.cs.EnsureLoadBalancer(context.TODO(), "test-cluster", env.service, env.nodes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestUpdateLoadBalancerPagination(t *testing.T) {
// Instances of a load balancer rule are one per load balanced node, so on a
// large cluster the un-paged response was truncated and the stale nodes on
// later pages were never removed from the rule.
const pageSize = 500
instances := make([]*cloudstack.VirtualMachine, 600)
for i := range instances {
instances[i] = &cloudstack.VirtualMachine{Id: fmt.Sprintf("vm-%d", i)}
}
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
// getLoadBalancer: one rule, fits in a single page.
mockLB.EXPECT().NewListLoadBalancerRulesParams().
Return(&cloudstack.ListLoadBalancerRulesParams{})
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).
Return(&cloudstack.ListLoadBalancerRulesResponse{
Count: 1,
LoadBalancerRules: []*cloudstack.LoadBalancerRule{
{Id: "rule-1", Name: "rule-1", Publicip: "1.2.3.4", Publicipid: "ip-1"},
},
}, nil)
// verifyHosts: the cluster is down to a single node.
mockVM.EXPECT().NewListVirtualMachinesParams().
Return(&cloudstack.ListVirtualMachinesParams{})
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).
Return(&cloudstack.ListVirtualMachinesResponse{
Count: 1,
VirtualMachines: []*cloudstack.VirtualMachine{
{Id: "vm-0", Name: "node-0", Nic: []cloudstack.Nic{{Networkid: "net-123"}}},
},
}, nil)
// The rule's members arrive a page at a time.
mockLB.EXPECT().NewListLoadBalancerRuleInstancesParams("rule-1").
Return(&cloudstack.ListLoadBalancerRuleInstancesParams{})
mockLB.EXPECT().ListLoadBalancerRuleInstances(gomock.Any()).Times(2).
DoAndReturn(func(p *cloudstack.ListLoadBalancerRuleInstancesParams) (*cloudstack.ListLoadBalancerRuleInstancesResponse, error) {
return &cloudstack.ListLoadBalancerRuleInstancesResponse{
Count: len(instances),
LoadBalancerRuleInstances: pageOf(t, p, instances, pageSize),
}, nil
})
var removed []string
mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-1").
Return(&cloudstack.RemoveFromLoadBalancerRuleParams{})
mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).
DoAndReturn(func(p *cloudstack.RemoveFromLoadBalancerRuleParams) (*cloudstack.RemoveFromLoadBalancerRuleResponse, error) {
removed, _ = p.GetVirtualmachineids()
return &cloudstack.RemoveFromLoadBalancerRuleResponse{}, nil
})
cs := &CSCloud{
client: &cloudstack.CloudStackClient{VirtualMachine: mockVM, LoadBalancer: mockLB},
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "default", UID: "abc123"},
}
if err := cs.UpdateLoadBalancer(context.TODO(), "cluster", service, nodesNamed("node-0")); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Every instance except vm-0 must be removed, including those that only
// appeared on the second page.
if len(removed) != len(instances)-1 {
t.Fatalf("removed %d hosts, want %d", len(removed), len(instances)-1)
}
if slices.Contains(removed, "vm-0") {
t.Errorf("vm-0 is still a node but was removed from the rule")
}
if !slices.Contains(removed, "vm-599") {
t.Errorf("vm-599 is on the second page and should have been removed, got %v", removed)
}
}
// A Network ACL rule is scoped to a network, so an obsolete rule left over in another network
// is not protected by a desired port that happens to share its protocol and port.
func TestPruneRulesScopesNetworkACLsToTheirNetwork(t *testing.T) {
aclNetwork := &cloudstack.Network{
Id: "net-new",
Service: []cloudstack.NetworkServiceInternal{{Name: "NetworkACL"}},
}
firewallNetwork := &cloudstack.Network{
Id: "net-new",
Service: []cloudstack.NetworkServiceInternal{{Name: "Firewall"}},
}
obsoleteRuleTier := &cloudstack.Network{
Id: "net-old",
Service: []cloudstack.NetworkServiceInternal{{Name: "NetworkACL"}},
}
desired := []desiredLBRule{{
name: "atestuid-tcp-80",
port: corev1.ServicePort{Port: 80, NodePort: 30000, Protocol: corev1.ProtocolTCP},
protocol: LoadBalancerProtocolTCP,
}}
desiredOn443 := []desiredLBRule{{
name: "atestuid-tcp-443",
port: corev1.ServicePort{Port: 443, NodePort: 30443, Protocol: corev1.ProtocolTCP},
protocol: LoadBalancerProtocolTCP,
}}
obsoleteIn := func(networkID string) []obsoleteRule {
return []obsoleteRule{{
rule: &cloudstack.LoadBalancerRule{
Id: "rule-old",
Name: "atestuid-tcp-80",
Publicipid: "ip-2",
Publicport: "80",
Protocol: "tcp",
Networkid: networkID,
},
protocol: LoadBalancerProtocolTCP,
tuple: portProtocol{"tcp", 80},
}}
}
obsoleteWithoutNetworkOn := func(publicIPID string) []obsoleteRule {
obsolete := obsoleteIn("")
obsolete[0].rule.Publicipid = publicIPID
return obsolete
}
type pruneMocks struct {
acl *cloudstack.MockNetworkACLServiceIface
network *cloudstack.MockNetworkServiceIface
firewall *cloudstack.MockFirewallServiceIface
}
newLB := func(ctrl *gomock.Controller) (*loadBalancer, *pruneMocks) {
m := &pruneMocks{
acl: cloudstack.NewMockNetworkACLServiceIface(ctrl),
network: cloudstack.NewMockNetworkServiceIface(ctrl),
firewall: cloudstack.NewMockFirewallServiceIface(ctrl),
}
mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-old").Return(&cloudstack.DeleteLoadBalancerRuleParams{})
mockLB.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil)
return &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: mockLB,
NetworkACL: m.acl,
Network: m.network,
Firewall: m.firewall,
},
networkID: "net-new",
ipAddrID: "ip-current",
}, m
}
expectACLRuleDeleted := func(m *pruneMocks) *cloudstack.ListNetworkACLsParams {
listParams := &cloudstack.ListNetworkACLsParams{}
gomock.InOrder(
m.acl.EXPECT().NewListNetworkACLsParams().Return(listParams),
m.acl.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
{Id: "acl-rule-old", Protocol: "tcp", Startport: "80", Endport: "80"},
},
}, nil),
m.acl.EXPECT().NewDeleteNetworkACLParams("acl-rule-old").Return(&cloudstack.DeleteNetworkACLParams{}),
m.acl.EXPECT().DeleteNetworkACL(gomock.Any()).Return(&cloudstack.DeleteNetworkACLResponse{}, nil),
)
return listParams
}
t.Run("a rule in another network has its ACL rule deleted there", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
lb, m := newLB(ctrl)
m.network.EXPECT().GetNetworkByID("net-old", gomock.Any()).Return(obsoleteRuleTier, 1, nil)
listParams := expectACLRuleDeleted(m)
if err := lb.pruneRules(obsoleteIn("net-old"), desired, aclNetwork); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if networkID, _ := listParams.GetNetworkid(); networkID != "net-old" {
t.Errorf("ACL rules listed on network %q, want the obsolete rule's own %q", networkID, "net-old")
}
})
t.Run("a rule in the reconciled network keeps its claimed ACL rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
// No ACL expectations: the desired tcp/80 port still claims that opening. No network
// lookup either: the reconciled network is already known.
lb, _ := newLB(ctrl)
if err := lb.pruneRules(obsoleteIn("net-new"), desired, aclNetwork); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("a rule in a VPC tier is cleaned there while a firewall network reconciles", func(t *testing.T) {
// Choosing the mechanism from the reconciled network would delete a firewall rule
// that does not exist and leave this ACL rule open.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
lb, m := newLB(ctrl)
m.network.EXPECT().GetNetworkByID("net-old", gomock.Any()).Return(obsoleteRuleTier, 1, nil)
listParams := expectACLRuleDeleted(m)
if err := lb.pruneRules(obsoleteIn("net-old"), desiredOn443, firewallNetwork); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if networkID, _ := listParams.GetNetworkid(); networkID != "net-old" {
t.Errorf("ACL rules listed on network %q, want the obsolete rule's own %q", networkID, "net-old")
}
})
t.Run("a rule in a deleted network keeps its ACL rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
lb, m := newLB(ctrl)
// GetNetworkByID reports not-found as an error alongside a count of 0.
m.network.EXPECT().GetNetworkByID("net-old", gomock.Any()).Return(nil, 0, fmt.Errorf("No match found for net-old"))
// Only the IP-scoped firewall rule is attempted: no network remains to place an
// ACL rule in.
gomock.InOrder(
m.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
m.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{}, nil),
)
if err := lb.pruneRules(obsoleteIn("net-old"), desiredOn443, aclNetwork); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("a failed network lookup keeps the rule and reports the error", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
// No delete expectations: the rule must survive until its network can be looked up.
mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
mockNetwork.EXPECT().GetNetworkByID("net-old", gomock.Any()).Return(nil, -1, fmt.Errorf("API unavailable"))
lb := &loadBalancer{
CloudStackClient: &cloudstack.CloudStackClient{
LoadBalancer: cloudstack.NewMockLoadBalancerServiceIface(ctrl),
Network: mockNetwork,
},
networkID: "net-new",
ipAddrID: "ip-current",
}
if err := lb.pruneRules(obsoleteIn("net-old"), desiredOn443, aclNetwork); err == nil {
t.Fatalf("expected the lookup failure to be reported")
}
})
t.Run("a rule with no network has only its firewall rule deleted", func(t *testing.T) {
// An ACL rule deleted in a guessed network could be the only opening another service
// has, while a firewall rule is scoped to this rule's own public IP.
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
lb, m := newLB(ctrl)
gomock.InOrder(
m.firewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{}),
m.firewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{}, nil),
)
if err := lb.pruneRules(obsoleteWithoutNetworkOn("ip-2"), desiredOn443, aclNetwork); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("a rule with no network on the reconciled IP uses that network", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
lb, m := newLB(ctrl)
listParams := expectACLRuleDeleted(m)
if err := lb.pruneRules(obsoleteWithoutNetworkOn("ip-current"), desiredOn443, aclNetwork); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if networkID, _ := listParams.GetNetworkid(); networkID != "net-new" {
t.Errorf("ACL rules listed on network %q, want the reconciled %q", networkID, "net-new")
}
})
}
// 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,"isflag":false,"description":" "}],"description":"This is loadbalancer cookie based stickiness method."},{"methodname":"AppCookie","paramlist":[{"paramname":"cookie-name","required":false,"isflag":false,"description":" "},{"paramname":"length","required":false,"isflag":false,"description":" "},{"paramname":"holdtime","required":false,"isflag":false,"description":" "},{"paramname":"request-learn","required":false,"isflag":true,"description":" "},{"paramname":"prefix","required":false,"isflag":true,"description":" "},{"paramname":"mode","required":false,"isflag":false,"description":" "}],"description":"This is App session based sticky method."},{"methodname":"SourceBased","paramlist":[{"paramname":"tablesize","required":false,"isflag":false,"description":" "},{"paramname":"expire","required":false,"isflag":false,"description":" "}],"description":"This is source based Stickiness method, it can be used for any type of protocol."}]`
// 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)
}
})
}