blob: 0b94c203c93ba2b4df518bb676b7e2b14acd451a [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"
"errors"
"reflect"
"slices"
"strings"
"testing"
"github.com/apache/cloudstack-go/v2/cloudstack"
"go.uber.org/mock/gomock"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type entryPointEnv struct {
cs *CSCloud
lb *cloudstack.MockLoadBalancerServiceIface
vm *cloudstack.MockVirtualMachineServiceIface
network *cloudstack.MockNetworkServiceIface
address *cloudstack.MockAddressServiceIface
fw *cloudstack.MockFirewallServiceIface
service *corev1.Service
}
func newEntryPointEnv(t *testing.T, projectID string) *entryPointEnv {
t.Helper()
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
e := &entryPointEnv{
lb: cloudstack.NewMockLoadBalancerServiceIface(ctrl),
vm: cloudstack.NewMockVirtualMachineServiceIface(ctrl),
network: cloudstack.NewMockNetworkServiceIface(ctrl),
address: cloudstack.NewMockAddressServiceIface(ctrl),
fw: cloudstack.NewMockFirewallServiceIface(ctrl),
service: &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace: "default", UID: "test-uid"}},
}
e.cs = &CSCloud{
projectID: projectID,
client: &cloudstack.CloudStackClient{
LoadBalancer: e.lb, VirtualMachine: e.vm, Network: e.network, Address: e.address, Firewall: e.fw,
},
}
return e
}
func tcpRule(id, port, ip, ipID string) *cloudstack.LoadBalancerRule {
return &cloudstack.LoadBalancerRule{
Id: id, Name: "atestuid-tcp-" + port, Publicport: port, Protocol: "tcp",
Publicip: ip, Publicipid: ipID, Networkid: "net-1",
}
}
// expectRuleListing expects the lookup of the Service's rules and checks the filter it sends.
func (e *entryPointEnv) expectRuleListing(t *testing.T, rules []*cloudstack.LoadBalancerRule, err error) *gomock.Call {
t.Helper()
e.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
return e.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).
DoAndReturn(func(p *cloudstack.ListLoadBalancerRulesParams) (*cloudstack.ListLoadBalancerRulesResponse, error) {
if keyword, _ := p.GetKeyword(); keyword != "atestuid" {
t.Errorf("rule listing keyword = %q, want atestuid", keyword)
}
if listAll, _ := p.GetListall(); !listAll {
t.Errorf("rule listing does not set listall")
}
if project, _ := p.GetProjectid(); project != e.cs.projectID {
t.Errorf("rule listing project = %q, want %q", project, e.cs.projectID)
}
if err != nil {
return nil, err
}
return &cloudstack.ListLoadBalancerRulesResponse{Count: len(rules), LoadBalancerRules: rules}, nil
})
}
// TestGetLoadBalancerStatus checks what Kubernetes is told about a Service's load balancer and that the
// rules are looked up by the Service's load balancer name in its project.
func TestGetLoadBalancerStatus(t *testing.T) {
tests := []struct {
name string
projectID string
rules []*cloudstack.LoadBalancerRule
err error
wantStatus *corev1.LoadBalancerStatus
wantExists bool
wantErr string
}{
{name: "no rules means no load balancer"},
{
name: "rules give the ingress IP",
rules: []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")},
wantStatus: &corev1.LoadBalancerStatus{Ingress: []corev1.LoadBalancerIngress{{IP: "203.0.113.1"}}},
wantExists: true,
},
{
name: "rules in a project",
projectID: testProjectID,
rules: []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")},
wantStatus: &corev1.LoadBalancerStatus{Ingress: []corev1.LoadBalancerIngress{{IP: "203.0.113.1"}}},
wantExists: true,
},
{name: "listing fails", err: errors.New("boom"), wantErr: "error retrieving load balancer rules: boom"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := newEntryPointEnv(t, tt.projectID)
env.expectRuleListing(t, tt.rules, tt.err)
status, exists, err := env.cs.GetLoadBalancer(context.TODO(), testClusterName, env.service)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("GetLoadBalancer() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil || exists != tt.wantExists {
t.Fatalf("GetLoadBalancer() = %v, %v, want exists %v", exists, err, tt.wantExists)
}
if (status == nil) != (tt.wantStatus == nil) || (status != nil && !reflect.DeepEqual(status, tt.wantStatus)) {
t.Errorf("GetLoadBalancer() status = %+v, want %+v", status, tt.wantStatus)
}
})
}
}
func (e *entryPointEnv) expectNodeVMs(vms ...*cloudstack.VirtualMachine) {
e.vm.EXPECT().NewListVirtualMachinesParams().Return(&cloudstack.ListVirtualMachinesParams{})
e.vm.EXPECT().ListVirtualMachines(gomock.Any()).
Return(&cloudstack.ListVirtualMachinesResponse{Count: len(vms), VirtualMachines: vms}, nil)
}
func nodeVM(id, name string) *cloudstack.VirtualMachine {
return &cloudstack.VirtualMachine{Id: id, Name: name, Nic: []cloudstack.Nic{{Networkid: "net-1"}}}
}
// expectRuleMembers answers the member listing of each rule by the rule ID the listing was built for.
func (e *entryPointEnv) expectRuleMembers(members map[string][]string, failFor string) {
e.lb.EXPECT().NewListLoadBalancerRuleInstancesParams(gomock.Any()).
DoAndReturn(func(id string) *cloudstack.ListLoadBalancerRuleInstancesParams {
p := &cloudstack.ListLoadBalancerRuleInstancesParams{}
p.SetId(id)
return p
}).AnyTimes()
e.lb.EXPECT().ListLoadBalancerRuleInstances(gomock.Any()).
DoAndReturn(func(p *cloudstack.ListLoadBalancerRuleInstancesParams) (*cloudstack.ListLoadBalancerRuleInstancesResponse, error) {
id, _ := p.GetId()
if id == failFor {
return nil, errors.New("boom")
}
var vms []*cloudstack.VirtualMachine
for _, vmID := range members[id] {
vms = append(vms, &cloudstack.VirtualMachine{Id: vmID})
}
return &cloudstack.ListLoadBalancerRuleInstancesResponse{Count: len(vms), LoadBalancerRuleInstances: vms}, nil
}).AnyTimes()
}
// recordMembershipChanges records the hosts assigned to and removed from each rule.
func (e *entryPointEnv) recordMembershipChanges() (assigned, removed map[string][]string) {
assigned, removed = map[string][]string{}, map[string][]string{}
e.lb.EXPECT().NewAssignToLoadBalancerRuleParams(gomock.Any()).
DoAndReturn(func(id string) *cloudstack.AssignToLoadBalancerRuleParams {
p := &cloudstack.AssignToLoadBalancerRuleParams{}
p.SetId(id)
return p
}).AnyTimes()
e.lb.EXPECT().AssignToLoadBalancerRule(gomock.Any()).
DoAndReturn(func(p *cloudstack.AssignToLoadBalancerRuleParams) (*cloudstack.AssignToLoadBalancerRuleResponse, error) {
id, _ := p.GetId()
ids, _ := p.GetVirtualmachineids()
assigned[id] = ids
return &cloudstack.AssignToLoadBalancerRuleResponse{}, nil
}).AnyTimes()
e.lb.EXPECT().NewRemoveFromLoadBalancerRuleParams(gomock.Any()).
DoAndReturn(func(id string) *cloudstack.RemoveFromLoadBalancerRuleParams {
p := &cloudstack.RemoveFromLoadBalancerRuleParams{}
p.SetId(id)
return p
}).AnyTimes()
e.lb.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).
DoAndReturn(func(p *cloudstack.RemoveFromLoadBalancerRuleParams) (*cloudstack.RemoveFromLoadBalancerRuleResponse, error) {
id, _ := p.GetId()
ids, _ := p.GetVirtualmachineids()
removed[id] = ids
return &cloudstack.RemoveFromLoadBalancerRuleResponse{}, nil
}).AnyTimes()
return assigned, removed
}
// TestUpdateLoadBalancerMembership checks that each rule is brought in line with the nodes by
// assigning only the missing hosts and removing only the stale ones.
func TestUpdateLoadBalancerMembership(t *testing.T) {
tests := []struct {
name string
members map[string][]string
wantAssigned map[string][]string
wantRemoved map[string][]string
}{
{
name: "rules already match",
members: map[string][]string{"rule-80": {"vm-1", "vm-2"}, "rule-8080": {"vm-2", "vm-1"}},
},
{
name: "a node was added",
members: map[string][]string{"rule-80": {"vm-1"}, "rule-8080": {"vm-1", "vm-2"}},
wantAssigned: map[string][]string{"rule-80": {"vm-2"}},
},
{
name: "a node was removed",
members: map[string][]string{"rule-80": {"vm-1", "vm-2", "vm-3"}, "rule-8080": {"vm-1", "vm-2"}},
wantRemoved: map[string][]string{"rule-80": {"vm-3"}},
},
{
name: "nodes were replaced on one rule only",
members: map[string][]string{"rule-80": {"vm-1", "vm-3"}, "rule-8080": {"vm-1", "vm-2"}},
wantAssigned: map[string][]string{"rule-80": {"vm-2"}},
wantRemoved: map[string][]string{"rule-80": {"vm-3"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := newEntryPointEnv(t, testProjectID)
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{
tcpRule("rule-80", "80", "203.0.113.1", "ip-1"),
tcpRule("rule-8080", "8080", "203.0.113.1", "ip-1"),
}, nil)
env.expectNodeVMs(nodeVM("vm-1", "node-1"), nodeVM("vm-2", "node-2"), nodeVM("vm-9", "other"))
env.expectRuleMembers(tt.members, "")
assigned, removed := env.recordMembershipChanges()
if err := env.cs.UpdateLoadBalancer(context.TODO(), testClusterName, env.service, nodesNamed("node-1", "node-2")); err != nil {
t.Fatalf("UpdateLoadBalancer() unexpected error: %v", err)
}
assertMembershipChanges(t, "assigned", assigned, tt.wantAssigned)
assertMembershipChanges(t, "removed", removed, tt.wantRemoved)
})
}
}
func assertMembershipChanges(t *testing.T, what string, got, want map[string][]string) {
t.Helper()
if len(got) != len(want) {
t.Errorf("%s hosts on rules %v, want %v", what, got, want)
return
}
for rule, wantIDs := range want {
gotIDs := slices.Clone(got[rule])
slices.Sort(gotIDs)
if !slices.Equal(gotIDs, wantIDs) {
t.Errorf("%s hosts on %s = %v, want %v", what, rule, gotIDs, wantIDs)
}
}
}
// TestUpdateLoadBalancerFailures checks that a failing lookup stops the update before any rule is changed.
func TestUpdateLoadBalancerFailures(t *testing.T) {
t.Run("rule listing fails", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, nil, errors.New("boom"))
err := env.cs.UpdateLoadBalancer(context.TODO(), testClusterName, env.service, nodesNamed("node-1"))
if err == nil || !strings.Contains(err.Error(), "error retrieving load balancer rules: boom") {
t.Errorf("UpdateLoadBalancer() error = %v, want the listing error", err)
}
})
t.Run("no node is known to CloudStack", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")}, nil)
env.expectNodeVMs(nodeVM("vm-9", "other"))
err := env.cs.UpdateLoadBalancer(context.TODO(), testClusterName, env.service, nodesNamed("node-1"))
if err == nil || !strings.Contains(err.Error(), "none of the hosts matched") {
t.Errorf("UpdateLoadBalancer() error = %v, want the host matching error", err)
}
})
t.Run("member listing fails", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")}, nil)
env.expectNodeVMs(nodeVM("vm-1", "node-1"))
env.expectRuleMembers(nil, "rule-80")
err := env.cs.UpdateLoadBalancer(context.TODO(), testClusterName, env.service, nodesNamed("node-1"))
if err == nil || !strings.Contains(err.Error(), "error retrieving associated instances: boom") {
t.Errorf("UpdateLoadBalancer() error = %v, want the member listing error", err)
}
})
t.Run("assigning hosts fails", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")}, nil)
env.expectNodeVMs(nodeVM("vm-1", "node-1"))
env.expectRuleMembers(map[string][]string{"rule-80": {}}, "")
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-80").Return(&cloudstack.AssignToLoadBalancerRuleParams{})
env.lb.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(nil, errors.New("boom"))
err := env.cs.UpdateLoadBalancer(context.TODO(), testClusterName, env.service, nodesNamed("node-1"))
if err == nil || !strings.Contains(err.Error(), "error assigning hosts to load balancer rule atestuid-tcp-80: boom") {
t.Errorf("UpdateLoadBalancer() error = %v, want the assign error", err)
}
})
t.Run("removing hosts fails", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")}, nil)
env.expectNodeVMs(nodeVM("vm-1", "node-1"))
env.expectRuleMembers(map[string][]string{"rule-80": {"vm-1", "vm-3"}}, "")
env.lb.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-80").Return(&cloudstack.RemoveFromLoadBalancerRuleParams{})
env.lb.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).Return(nil, errors.New("boom"))
err := env.cs.UpdateLoadBalancer(context.TODO(), testClusterName, env.service, nodesNamed("node-1"))
if err == nil || !strings.Contains(err.Error(), "error removing hosts from load balancer rule atestuid-tcp-80: boom") {
t.Errorf("UpdateLoadBalancer() error = %v, want the remove error", err)
}
})
}
// expectRuleDeletion expects the firewall cleanup and the deletion of one rule on a Firewall network.
func (e *entryPointEnv) expectRuleDeletion(t *testing.T, ruleID, ipID string) {
t.Helper()
e.network.EXPECT().GetNetworkByID("net-1", gomock.Any()).Return(&cloudstack.Network{Id: "net-1"}, 1, nil)
e.fw.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{})
e.fw.EXPECT().ListFirewallRules(gomock.Any()).
DoAndReturn(func(p *cloudstack.ListFirewallRulesParams) (*cloudstack.ListFirewallRulesResponse, error) {
if got, _ := p.GetIpaddressid(); got != ipID {
t.Errorf("firewall rules listed for IP %q, want %q", got, ipID)
}
return &cloudstack.ListFirewallRulesResponse{}, nil
})
e.lb.EXPECT().NewDeleteLoadBalancerRuleParams(ruleID).Return(&cloudstack.DeleteLoadBalancerRuleParams{})
e.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil)
}
func (e *entryPointEnv) expectIPLookup(t *testing.T, ipID string, ip *cloudstack.PublicIpAddress, err error) {
t.Helper()
e.address.EXPECT().GetPublicIpAddressByID(gomock.Any(), gomock.Any()).
DoAndReturn(func(id string, opts ...cloudstack.OptionFunc) (*cloudstack.PublicIpAddress, int, error) {
if id != ipID || appliedProjectID(t, opts) != e.cs.projectID {
t.Errorf("IP lookup of %q in project %q, want %q in %q", id, appliedProjectID(t, opts), ipID, e.cs.projectID)
}
if err != nil {
return nil, 0, err
}
return ip, 1, nil
})
}
func (e *entryPointEnv) expectRulesUsingIP(t *testing.T, ipID string, others []*cloudstack.LoadBalancerRule, err error) {
t.Helper()
e.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
e.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).
DoAndReturn(func(p *cloudstack.ListLoadBalancerRulesParams) (*cloudstack.ListLoadBalancerRulesResponse, error) {
if got, _ := p.GetPublicipid(); got != ipID {
t.Errorf("rules listed for IP %q, want %q", got, ipID)
}
if listAll, _ := p.GetListall(); !listAll {
t.Errorf("rules using the IP are listed without listall")
}
if project, _ := p.GetProjectid(); project != e.cs.projectID {
t.Errorf("rules using the IP listed in project %q, want %q", project, e.cs.projectID)
}
if err != nil {
return nil, err
}
return &cloudstack.ListLoadBalancerRulesResponse{Count: len(others), LoadBalancerRules: others}, nil
})
}
func (e *entryPointEnv) expectIPRelease(t *testing.T, ipID string, err error) {
t.Helper()
e.address.EXPECT().NewDisassociateIpAddressParams(ipID).Return(&cloudstack.DisassociateIpAddressParams{})
e.address.EXPECT().DisassociateIpAddress(gomock.Any()).Return(&cloudstack.DisassociateIpAddressResponse{}, err)
}
// TestEnsureLoadBalancerDeletedPublicIP checks which public IPs are released once the Service's
// rules are gone: one the controller allocated, and a user supplied one only when the controller
// associated it and no other Service still has rules on it.
func TestEnsureLoadBalancerDeletedPublicIP(t *testing.T) {
const ipID = "ip-1"
allocated := &cloudstack.PublicIpAddress{Id: ipID, Allocated: "2024-01-01T00:00:00+0000"}
otherRule := tcpRule("other-rule", "8080", "203.0.113.1", ipID)
tests := []struct {
name string
userIP string
annotation string
expect func(e *entryPointEnv, t *testing.T)
wantErrorMsg string
}{
{
name: "IP allocated by the controller is released",
expect: func(e *entryPointEnv, t *testing.T) {
e.expectIPRelease(t, ipID, nil)
},
},
{
name: "failure to release the IP is returned",
expect: func(e *entryPointEnv, t *testing.T) {
e.expectIPRelease(t, ipID, errors.New("boom"))
},
wantErrorMsg: "error releasing load balancer IP 203.0.113.1: boom",
},
{
name: "user IP the controller did not associate is kept",
userIP: "203.0.113.1",
expect: func(e *entryPointEnv, t *testing.T) {},
},
{
name: "user IP associated by the controller is released when no rule uses it",
userIP: "203.0.113.1",
annotation: "true",
expect: func(e *entryPointEnv, t *testing.T) {
e.expectIPLookup(t, ipID, allocated, nil)
e.expectRulesUsingIP(t, ipID, nil, nil)
e.expectIPRelease(t, ipID, nil)
},
},
{
name: "user IP associated by the controller is kept while another rule uses it",
userIP: "203.0.113.1",
annotation: "true",
expect: func(e *entryPointEnv, t *testing.T) {
e.expectIPLookup(t, ipID, allocated, nil)
e.expectRulesUsingIP(t, ipID, []*cloudstack.LoadBalancerRule{otherRule}, nil)
},
},
{
name: "user IP is kept when it cannot be looked up",
userIP: "203.0.113.1",
annotation: "true",
expect: func(e *entryPointEnv, t *testing.T) {
e.expectIPLookup(t, ipID, nil, errors.New("boom"))
},
},
{
name: "user IP is kept when the rules using it cannot be listed",
userIP: "203.0.113.1",
annotation: "true",
expect: func(e *entryPointEnv, t *testing.T) {
e.expectIPLookup(t, ipID, allocated, nil)
e.expectRulesUsingIP(t, ipID, nil, errors.New("boom"))
},
},
}
for _, tt := range tests {
for _, projectID := range []string{"", testProjectID} {
t.Run(tt.name+"/project "+projectID, func(t *testing.T) {
env := newEntryPointEnv(t, projectID)
env.service.Spec.LoadBalancerIP = tt.userIP
if tt.annotation != "" {
env.service.Annotations = map[string]string{ServiceAnnotationLoadBalancerIPAssociatedByController: tt.annotation}
}
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", ipID)}, nil)
env.expectRuleDeletion(t, "rule-80", ipID)
tt.expect(env, t)
err := env.cs.EnsureLoadBalancerDeleted(context.TODO(), testClusterName, env.service)
if tt.wantErrorMsg == "" && err != nil {
t.Errorf("EnsureLoadBalancerDeleted() unexpected error: %v", err)
}
if tt.wantErrorMsg != "" && (err == nil || err.Error() != tt.wantErrorMsg) {
t.Errorf("EnsureLoadBalancerDeleted() error = %v, want %q", err, tt.wantErrorMsg)
}
})
}
}
}
// TestEnsureLoadBalancerDeletedWithUnparsableRule checks that a rule whose protocol or port the
// provider cannot parse does not fail the deletion: an unknown protocol leaves the rule alone, and
// an unreadable port deletes the rule without touching the firewall.
func TestEnsureLoadBalancerDeletedWithUnparsableRule(t *testing.T) {
t.Run("unknown protocol leaves the rule alone", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.service.Spec.LoadBalancerIP = "203.0.113.1"
rule := tcpRule("rule-1", "80", "203.0.113.1", "ip-1")
rule.Protocol = "icmp"
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{rule}, nil)
if err := env.cs.EnsureLoadBalancerDeleted(context.TODO(), testClusterName, env.service); err != nil {
t.Errorf("EnsureLoadBalancerDeleted() unexpected error: %v", err)
}
})
t.Run("unreadable port deletes the rule without firewall cleanup", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.service.Spec.LoadBalancerIP = "203.0.113.1"
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-1", "http", "203.0.113.1", "ip-1")}, nil)
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-1").Return(&cloudstack.DeleteLoadBalancerRuleParams{})
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil)
if err := env.cs.EnsureLoadBalancerDeleted(context.TODO(), testClusterName, env.service); err != nil {
t.Errorf("EnsureLoadBalancerDeleted() unexpected error: %v", err)
}
})
}
// TestEnsureLoadBalancerDeletedFailures checks that a failing lookup is returned before any rule is deleted.
func TestEnsureLoadBalancerDeletedFailures(t *testing.T) {
t.Run("rule listing fails", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, nil, errors.New("boom"))
err := env.cs.EnsureLoadBalancerDeleted(context.TODO(), testClusterName, env.service)
if err == nil || !strings.Contains(err.Error(), "error retrieving load balancer rules: boom") {
t.Errorf("EnsureLoadBalancerDeleted() error = %v, want the listing error", err)
}
})
t.Run("network of the rule cannot be looked up", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")}, nil)
env.network.EXPECT().GetNetworkByID("net-1", gomock.Any()).Return(nil, 0, errors.New("boom"))
err := env.cs.EnsureLoadBalancerDeleted(context.TODO(), testClusterName, env.service)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Errorf("EnsureLoadBalancerDeleted() error = %v, want the network error", err)
}
})
t.Run("deleting the rule fails", func(t *testing.T) {
env := newEntryPointEnv(t, "")
env.expectRuleListing(t, []*cloudstack.LoadBalancerRule{tcpRule("rule-80", "80", "203.0.113.1", "ip-1")}, nil)
env.network.EXPECT().GetNetworkByID("net-1", gomock.Any()).Return(&cloudstack.Network{Id: "net-1"}, 1, nil)
env.fw.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{})
env.fw.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{}, nil)
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-80").Return(&cloudstack.DeleteLoadBalancerRuleParams{})
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(nil, errors.New("boom"))
err := env.cs.EnsureLoadBalancerDeleted(context.TODO(), testClusterName, env.service)
if err == nil || !strings.Contains(err.Error(), "error deleting load balancer rule atestuid-tcp-80: boom") {
t.Errorf("EnsureLoadBalancerDeleted() error = %v, want the delete error", err)
}
})
}
// TestEnsureLoadBalancerRejectsBeforeCreatingAnything checks that a Service or lookup that cannot
// be served fails before an IP, rule or firewall entry is created: the mocks have no expectation
// for any of those calls, so one would fail the test.
func TestEnsureLoadBalancerRejectsBeforeCreatingAnything(t *testing.T) {
http := corev1.ServicePort{Name: "http", Port: 80, NodePort: 30000, Protocol: corev1.ProtocolTCP}
tests := []struct {
name string
ports []corev1.ServicePort
setup func(e *ensureLBTestEnv)
wantErr string
}{
{
name: "no ports",
setup: func(e *ensureLBTestEnv) {},
wantErr: "requested load balancer with no ports",
},
{
name: "rule listing fails",
ports: []corev1.ServicePort{http},
setup: func(e *ensureLBTestEnv) {
e.lb.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
e.lb.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(nil, errors.New("boom"))
},
wantErr: "error retrieving load balancer rules: boom",
},
{
name: "unsupported session affinity",
ports: []corev1.ServicePort{http},
setup: func(e *ensureLBTestEnv) {
e.service.Spec.SessionAffinity = "Bogus"
e.expectRules()
},
wantErr: "unsupported load balancer affinity: Bogus",
},
{
name: "nodes cannot be listed",
ports: []corev1.ServicePort{http},
setup: func(e *ensureLBTestEnv) {
e.expectRules()
e.vm.EXPECT().NewListVirtualMachinesParams().Return(&cloudstack.ListVirtualMachinesParams{})
e.vm.EXPECT().ListVirtualMachines(gomock.Any()).Return(nil, errors.New("boom"))
},
wantErr: "error retrieving list of hosts: boom",
},
{
name: "network cannot be looked up",
ports: []corev1.ServicePort{http},
setup: func(e *ensureLBTestEnv) {
e.expectRules()
e.expectHosts()
e.network.EXPECT().GetNetworkByID("net-1", gomock.Any()).Return(nil, 0, errors.New("boom"))
},
wantErr: "boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
env := newEnsureLBTestEnv(ctrl, nil, tt.ports)
tt.setup(env)
status, err := env.cs.EnsureLoadBalancer(context.TODO(), testClusterName, env.service, env.nodes)
if status != nil || err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("EnsureLoadBalancer() = %v, %v, want an error containing %q", status, err, tt.wantErr)
}
})
}
}
// TestEnsureLoadBalancerReportsHostnameAnnotation checks that a hostname annotation replaces the
// IP in the status Kubernetes publishes, while the rules are still created on the IP.
func TestEnsureLoadBalancerReportsHostnameAnnotation(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
http := corev1.ServicePort{Name: "http", Port: 80, NodePort: 30000, Protocol: corev1.ProtocolTCP}
env := newEnsureLBTestEnv(ctrl, map[string]string{ServiceAnnotationLoadBalancerLoadbalancerHostname: "lb.example.com"}, []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{})
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)
status, err := env.cs.EnsureLoadBalancer(context.TODO(), testClusterName, env.service, env.nodes)
want := &corev1.LoadBalancerStatus{Ingress: []corev1.LoadBalancerIngress{{Hostname: "lb.example.com"}}}
if err != nil || !reflect.DeepEqual(status, want) {
t.Errorf("EnsureLoadBalancer() = %v, %v, want %v", status, err, want)
}
}