This is an automated email from the ASF dual-hosted git repository.
DaanHoogland pushed a commit to branch main
in repository
https://gitbox.apache.org/repos/asf/cloudstack-kubernetes-provider.git
The following commit(s) were added to refs/heads/main by this push:
new 363cd035 Add unit tests (#110)
363cd035 is described below
commit 363cd035ea2744929e3928498f3da44e3b3c3155
Author: Vishesh <[email protected]>
AuthorDate: Mon Oct 26 22:00:31 2026 +0530
Add unit tests (#110)
---
cloudstack_annotation_test.go | 184 ++++++++
cloudstack_loadbalancer_entrypoints_test.go | 676 ++++++++++++++++++++++++++++
cloudstack_node_lookup_test.go | 425 +++++++++++++++++
cloudstack_zone_test.go | 279 ++++++++++++
4 files changed, 1564 insertions(+)
diff --git a/cloudstack_annotation_test.go b/cloudstack_annotation_test.go
new file mode 100644
index 00000000..22e8c150
--- /dev/null
+++ b/cloudstack_annotation_test.go
@@ -0,0 +1,184 @@
+/*
+ * 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"
+ "strings"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/kubernetes/fake"
+ k8stesting "k8s.io/client-go/testing"
+)
+
+const annotationKey =
"service.beta.kubernetes.io/cloudstack-load-balancer-ip-associated-by-controller"
+
+var servicesResource = schema.GroupResource{Resource: "services"}
+
+func annotatedService(annotations map[string]string) *corev1.Service {
+ return &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace:
"default", Annotations: annotations},
+ }
+}
+
+func patchActions(client *fake.Clientset) []k8stesting.PatchAction {
+ var patches []k8stesting.PatchAction
+ for _, action := range client.Actions() {
+ if patch, ok := action.(k8stesting.PatchAction); ok {
+ patches = append(patches, patch)
+ }
+ }
+ return patches
+}
+
+func failPatches(client *fake.Clientset, results ...error) {
+ attempt := 0
+ client.PrependReactor("patch", "services", func(k8stesting.Action)
(bool, runtime.Object, error) {
+ err := results[min(attempt, len(results)-1)]
+ attempt++
+ return err != nil, nil, err
+ })
+}
+
+// TestSetServiceAnnotation checks when an annotation is patched and that only
the one key is sent.
+func TestSetServiceAnnotation(t *testing.T) {
+ t.Run("no client builder skips the update", func(t *testing.T) {
+ cs := &CSCloud{}
+
+ if err := cs.setServiceAnnotation(context.TODO(),
annotatedService(nil), annotationKey, "true"); err != nil {
+ t.Errorf("setServiceAnnotation() unexpected error: %v",
err)
+ }
+ })
+
+ t.Run("client cannot be built", func(t *testing.T) {
+ cs := &CSCloud{clientBuilder: &fakeClientBuilder{err:
errors.New("no config")}}
+
+ err := cs.setServiceAnnotation(context.TODO(),
annotatedService(nil), annotationKey, "true")
+
+ if err == nil || !strings.Contains(err.Error(), "failed to get
Kubernetes client: no config") {
+ t.Errorf("setServiceAnnotation() error = %v, want the
client error", err)
+ }
+ })
+
+ t.Run("missing service is skipped", func(t *testing.T) {
+ client := fake.NewSimpleClientset()
+ cs := &CSCloud{clientBuilder: &fakeClientBuilder{client:
client}}
+
+ if err := cs.setServiceAnnotation(context.TODO(),
annotatedService(nil), annotationKey, "true"); err != nil {
+ t.Errorf("setServiceAnnotation() unexpected error: %v",
err)
+ }
+ if got := len(patchActions(client)); got != 0 {
+ t.Errorf("got %d patches, want none", got)
+ }
+ })
+
+ t.Run("service lookup fails", func(t *testing.T) {
+ client := fake.NewSimpleClientset()
+ client.PrependReactor("get", "services",
func(k8stesting.Action) (bool, runtime.Object, error) {
+ return true, nil, errors.New("boom")
+ })
+ cs := &CSCloud{clientBuilder: &fakeClientBuilder{client:
client}}
+
+ err := cs.setServiceAnnotation(context.TODO(),
annotatedService(nil), annotationKey, "true")
+
+ if err == nil || !strings.Contains(err.Error(), "failed to get
service: boom") {
+ t.Errorf("setServiceAnnotation() error = %v, want the
lookup error", err)
+ }
+ })
+
+ t.Run("annotation already set is not patched", func(t *testing.T) {
+ client :=
fake.NewSimpleClientset(annotatedService(map[string]string{annotationKey:
"true"}))
+ cs := &CSCloud{clientBuilder: &fakeClientBuilder{client:
client}}
+
+ if err := cs.setServiceAnnotation(context.TODO(),
annotatedService(nil), annotationKey, "true"); err != nil {
+ t.Errorf("setServiceAnnotation() unexpected error: %v",
err)
+ }
+ if got := len(patchActions(client)); got != 0 {
+ t.Errorf("got %d patches, want none", got)
+ }
+ })
+
+ t.Run("different value is merge patched", func(t *testing.T) {
+ client :=
fake.NewSimpleClientset(annotatedService(map[string]string{annotationKey:
"false", "other": "kept"}))
+ cs := &CSCloud{clientBuilder: &fakeClientBuilder{client:
client}}
+
+ if err := cs.setServiceAnnotation(context.TODO(),
annotatedService(nil), annotationKey, "true"); err != nil {
+ t.Fatalf("setServiceAnnotation() unexpected error: %v",
err)
+ }
+
+ patches := patchActions(client)
+ if len(patches) != 1 {
+ t.Fatalf("got %d patches, want 1", len(patches))
+ }
+ wantPatch := `{"metadata":{"annotations":{"` + annotationKey +
`":"true"}}}`
+ if patches[0].GetName() != "web" || patches[0].GetNamespace()
!= "default" ||
+ patches[0].GetPatchType() != types.MergePatchType ||
string(patches[0].GetPatch()) != wantPatch {
+ t.Errorf("patch = %s %s/%s %s, want a merge patch of
web %s", patches[0].GetPatchType(), patches[0].GetNamespace(),
patches[0].GetName(), patches[0].GetPatch(), wantPatch)
+ }
+ svc, err :=
client.CoreV1().Services("default").Get(context.TODO(), "web",
metav1.GetOptions{})
+ if err != nil || svc.Annotations[annotationKey] != "true" ||
svc.Annotations["other"] != "kept" {
+ t.Errorf("annotations after the patch = %v, %v, want
the key updated and the other one kept", svc.Annotations, err)
+ }
+ })
+}
+
+// TestPatchServiceAnnotation checks how a failing patch is retried or given
up on.
+func TestPatchServiceAnnotation(t *testing.T) {
+ conflict := apierrors.NewConflict(servicesResource, "web",
errors.New("changed"))
+ tests := []struct {
+ name string
+ results []error
+ wantPatches int
+ wantErr string
+ }{
+ {name: "first attempt succeeds", results: []error{nil},
wantPatches: 1},
+ {name: "conflict then success", results: []error{conflict,
nil}, wantPatches: 2},
+ {name: "conflicts exhaust the retries", results:
[]error{conflict}, wantPatches: 3, wantErr: "failed to update service
annotation after 3 retries due to conflicts"},
+ {name: "service deleted meanwhile", results:
[]error{apierrors.NewNotFound(servicesResource, "web")}, wantPatches: 1},
+ {name: "other error is not retried", results:
[]error{errors.New("boom")}, wantPatches: 1, wantErr: "failed to patch service
annotation: boom"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := fake.NewSimpleClientset(annotatedService(nil))
+ failPatches(client, tt.results...)
+ cs := &CSCloud{}
+
+ err := cs.patchServiceAnnotation(context.TODO(),
client, "default", "web", annotationKey, "true")
+
+ if tt.wantErr == "" && err != nil {
+ t.Errorf("patchServiceAnnotation() unexpected
error: %v", err)
+ }
+ if tt.wantErr != "" && (err == nil ||
!strings.Contains(err.Error(), tt.wantErr)) {
+ t.Errorf("patchServiceAnnotation() error = %v,
want it to contain %q", err, tt.wantErr)
+ }
+ if got := len(patchActions(client)); got !=
tt.wantPatches {
+ t.Errorf("got %d patch attempts, want %d", got,
tt.wantPatches)
+ }
+ })
+ }
+}
diff --git a/cloudstack_loadbalancer_entrypoints_test.go
b/cloudstack_loadbalancer_entrypoints_test.go
new file mode 100644
index 00000000..0b94c203
--- /dev/null
+++ b/cloudstack_loadbalancer_entrypoints_test.go
@@ -0,0 +1,676 @@
+/*
+ * 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)
+ }
+}
diff --git a/cloudstack_node_lookup_test.go b/cloudstack_node_lookup_test.go
new file mode 100644
index 00000000..f6aff4f1
--- /dev/null
+++ b/cloudstack_node_lookup_test.go
@@ -0,0 +1,425 @@
+/*
+ * 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"
+ "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"
+ "k8s.io/apimachinery/pkg/types"
+ cloudprovider "k8s.io/cloud-provider"
+)
+
+const testProjectID = "6a3c1b9e-4f3d-4b43-9a6a-0c3d1f2e7b11"
+
+type projectRecorder struct{ projectID string }
+
+func (r *projectRecorder) SetProjectid(id string) { r.projectID = id }
+
+// appliedProjectID returns the project id the lookup options would put on a
request.
+func appliedProjectID(t *testing.T, opts []cloudstack.OptionFunc) string {
+ t.Helper()
+ recorder := &projectRecorder{}
+ for _, opt := range opts {
+ if err := opt(nil, recorder); err != nil {
+ t.Fatalf("applying lookup option: %v", err)
+ }
+ }
+ return recorder.projectID
+}
+
+type vmLookupEnv struct {
+ cs *CSCloud
+ vm *cloudstack.MockVirtualMachineServiceIface
+}
+
+func newVMLookupEnv(t *testing.T, projectID string) *vmLookupEnv {
+ t.Helper()
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ vm := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
+ return &vmLookupEnv{
+ cs: &CSCloud{
+ client: &cloudstack.CloudStackClient{VirtualMachine:
vm},
+ projectID: projectID,
+ },
+ vm: vm,
+ }
+}
+
+// expectByName expects one lookup of the instance by name, scoped to the
CSCloud's project.
+func (e *vmLookupEnv) expectByName(t *testing.T, name string, result
*cloudstack.VirtualMachine, count int, err error) *gomock.Call {
+ t.Helper()
+ return e.vm.EXPECT().GetVirtualMachineByName(gomock.Any(),
gomock.Any()).
+ DoAndReturn(func(got string, opts ...cloudstack.OptionFunc)
(*cloudstack.VirtualMachine, int, error) {
+ if got != name {
+ t.Errorf("GetVirtualMachineByName name = %q,
want %q", got, name)
+ }
+ if gotProject := appliedProjectID(t, opts); gotProject
!= e.cs.projectID {
+ t.Errorf("GetVirtualMachineByName project = %q,
want %q", gotProject, e.cs.projectID)
+ }
+ return result, count, err
+ })
+}
+
+// expectByID expects one lookup of the instance by ID, scoped to the
CSCloud's project.
+func (e *vmLookupEnv) expectByID(t *testing.T, id string, result
*cloudstack.VirtualMachine, count int, err error) *gomock.Call {
+ t.Helper()
+ return e.vm.EXPECT().GetVirtualMachineByID(gomock.Any(), gomock.Any()).
+ DoAndReturn(func(got string, opts ...cloudstack.OptionFunc)
(*cloudstack.VirtualMachine, int, error) {
+ if got != id {
+ t.Errorf("GetVirtualMachineByID id = %q, want
%q", got, id)
+ }
+ if gotProject := appliedProjectID(t, opts); gotProject
!= e.cs.projectID {
+ t.Errorf("GetVirtualMachineByID project = %q,
want %q", gotProject, e.cs.projectID)
+ }
+ return result, count, err
+ })
+}
+
+func testInstance() *cloudstack.VirtualMachine {
+ return &cloudstack.VirtualMachine{
+ Id: "vm-1",
+ Name: "node-1",
+ Zonename: "zone-a",
+ Serviceofferingname: "Small Instance",
+ Nic: []cloudstack.Nic{{Ipaddress: "10.0.0.1"}},
+ }
+}
+
+type instanceLookupOp struct {
+ name string
+ call func(cs *CSCloud, key string) (any, error)
+ want any
+ notFound any
+ notFoundErr error
+ errPrefix string
+}
+
+var instanceLookupOps = []instanceLookupOp{
+ {
+ name: "NodeAddresses",
+ call: func(cs *CSCloud, key string) (any, error) {
+ return cs.NodeAddresses(context.TODO(),
types.NodeName(key))
+ },
+ want: []corev1.NodeAddress{{Type: corev1.NodeInternalIP,
Address: "10.0.0.1"}},
+ notFound: []corev1.NodeAddress(nil),
+ notFoundErr: cloudprovider.InstanceNotFound,
+ errPrefix: "error retrieving node addresses",
+ },
+ {
+ name: "InstanceID",
+ call: func(cs *CSCloud, key string) (any, error) {
+ return cs.InstanceID(context.TODO(),
types.NodeName(key))
+ },
+ want: "vm-1",
+ notFound: "",
+ notFoundErr: cloudprovider.InstanceNotFound,
+ errPrefix: "error retrieving instance ID",
+ },
+ {
+ name: "InstanceType",
+ call: func(cs *CSCloud, key string) (any, error) {
+ return cs.InstanceType(context.TODO(),
types.NodeName(key))
+ },
+ want: "SmallInstance",
+ notFound: "",
+ notFoundErr: cloudprovider.InstanceNotFound,
+ errPrefix: "error retrieving instance type",
+ },
+}
+
+var providerIDLookupOps = []instanceLookupOp{
+ {
+ name: "NodeAddressesByProviderID",
+ call: func(cs *CSCloud, key string) (any, error) {
+ return cs.NodeAddressesByProviderID(context.TODO(), key)
+ },
+ want: []corev1.NodeAddress{{Type: corev1.NodeInternalIP,
Address: "10.0.0.1"}},
+ notFound: []corev1.NodeAddress(nil),
+ notFoundErr: cloudprovider.InstanceNotFound,
+ errPrefix: "error retrieving node addresses",
+ },
+ {
+ name: "InstanceTypeByProviderID",
+ call: func(cs *CSCloud, key string) (any, error) {
+ return cs.InstanceTypeByProviderID(context.TODO(), key)
+ },
+ want: "SmallInstance",
+ notFound: "",
+ notFoundErr: cloudprovider.InstanceNotFound,
+ errPrefix: "error retrieving instance type",
+ },
+ {
+ name: "InstanceExistsByProviderID",
+ call: func(cs *CSCloud, key string) (any, error) {
+ return cs.InstanceExistsByProviderID(context.TODO(),
key)
+ },
+ want: true,
+ notFound: false,
+ errPrefix: "error retrieving instance",
+ },
+}
+
+type lookupScenario struct {
+ name string
+ projectID string
+ result *cloudstack.VirtualMachine
+ count int
+ err error
+}
+
+func lookupScenarios() []lookupScenario {
+ return []lookupScenario{
+ {name: "found", result: testInstance(), count: 1},
+ {name: "found in a project", projectID: testProjectID, result:
testInstance(), count: 1},
+ {name: "not found", count: 0, err: errors.New("no match")},
+ {name: "api error", count: 1, err: errors.New("boom")},
+ }
+}
+
+func assertLookupOutcome(t *testing.T, op instanceLookupOp, sc lookupScenario,
got any, err error) {
+ t.Helper()
+ switch {
+ case sc.err == nil:
+ if err != nil {
+ t.Fatalf("%s() unexpected error: %v", op.name, err)
+ }
+ if !reflect.DeepEqual(got, op.want) {
+ t.Errorf("%s() = %v, want %v", op.name, got, op.want)
+ }
+ case sc.count == 0:
+ if err != op.notFoundErr {
+ t.Errorf("%s() error = %v, want %v", op.name, err,
op.notFoundErr)
+ }
+ if !reflect.DeepEqual(got, op.notFound) {
+ t.Errorf("%s() = %v, want %v", op.name, got,
op.notFound)
+ }
+ default:
+ if err == nil || !strings.HasPrefix(err.Error(), op.errPrefix)
|| !strings.Contains(err.Error(), sc.err.Error()) {
+ t.Errorf("%s() error = %v, want prefix %q wrapping %q",
op.name, err, op.errPrefix, sc.err)
+ }
+ }
+}
+
+// TestInstanceLookupsByName checks that every node-name lookup asks
CloudStack for that name in
+// the configured project and maps a miss and an API failure to the errors the
node controller expects.
+func TestInstanceLookupsByName(t *testing.T) {
+ for _, op := range instanceLookupOps {
+ for _, sc := range lookupScenarios() {
+ t.Run(op.name+"/"+sc.name, func(t *testing.T) {
+ env := newVMLookupEnv(t, sc.projectID)
+ env.expectByName(t, "node-1", sc.result,
sc.count, sc.err)
+
+ got, err := op.call(env.cs, "node-1")
+
+ assertLookupOutcome(t, op, sc, got, err)
+ })
+ }
+ }
+}
+
+// TestInstanceLookupsByProviderID checks that every provider ID lookup strips
the scheme before
+// asking CloudStack for the instance and accepts a bare instance ID as well.
+func TestInstanceLookupsByProviderID(t *testing.T) {
+ providerIDs := map[string]string{
+ "with scheme": "external-cloudstack://vm-1",
+ "bare id": "vm-1",
+ }
+
+ for _, op := range providerIDLookupOps {
+ for _, sc := range lookupScenarios() {
+ for idName, providerID := range providerIDs {
+ t.Run(op.name+"/"+sc.name+"/"+idName, func(t
*testing.T) {
+ env := newVMLookupEnv(t, sc.projectID)
+ env.expectByID(t, "vm-1", sc.result,
sc.count, sc.err)
+
+ got, err := op.call(env.cs, providerID)
+
+ assertLookupOutcome(t, op, sc, got, err)
+ })
+ }
+ }
+ }
+}
+
+// TestInstanceTypeStripsInvalidLabelCharacters checks that the service
offering name is turned
+// into a value that is valid in a node label.
+func TestInstanceTypeStripsInvalidLabelCharacters(t *testing.T) {
+ tests := map[string]string{
+ "Small Instance": "SmallInstance",
+ "Medium": "Medium",
+ "Big (x2)": "Bigx2",
+ }
+
+ for offering, want := range tests {
+ t.Run(offering, func(t *testing.T) {
+ env := newVMLookupEnv(t, "")
+ env.expectByName(t, "node-1",
&cloudstack.VirtualMachine{Serviceofferingname: offering}, 1, nil)
+
+ got, err := env.cs.InstanceType(context.TODO(),
"node-1")
+
+ if err != nil || got != want {
+ t.Errorf("InstanceType() = %q, %v, want %q",
got, err, want)
+ }
+ })
+ }
+}
+
+// TestInstanceExists checks that the node is resolved to its ID by name
before the existence check.
+func TestInstanceExists(t *testing.T) {
+ tests := []struct {
+ name string
+ byName func(e *vmLookupEnv, t *testing.T)
+ byID func(e *vmLookupEnv, t *testing.T)
+ want bool
+ wantErr error
+ errContains string
+ }{
+ {
+ name: "instance exists",
+ byName: func(e *vmLookupEnv, t *testing.T) {
e.expectByName(t, "node-1", testInstance(), 1, nil) },
+ byID: func(e *vmLookupEnv, t *testing.T) {
e.expectByID(t, "vm-1", testInstance(), 1, nil) },
+ want: true,
+ },
+ {
+ name: "node unknown to CloudStack",
+ byName: func(e *vmLookupEnv, t *testing.T) {
e.expectByName(t, "node-1", nil, 0, errors.New("no match")) },
+ wantErr: cloudprovider.InstanceNotFound,
+ },
+ {
+ name: "instance deleted after the name lookup",
+ byName: func(e *vmLookupEnv, t *testing.T) {
e.expectByName(t, "node-1", testInstance(), 1, nil) },
+ byID: func(e *vmLookupEnv, t *testing.T) {
e.expectByID(t, "vm-1", nil, 0, errors.New("no match")) },
+ want: false,
+ },
+ {
+ name: "api error on the ID lookup",
+ byName: func(e *vmLookupEnv, t *testing.T) {
e.expectByName(t, "node-1", testInstance(), 1, nil) },
+ byID: func(e *vmLookupEnv, t *testing.T) {
e.expectByID(t, "vm-1", nil, 1, errors.New("boom")) },
+ errContains: "error retrieving instance: boom",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ env := newVMLookupEnv(t, testProjectID)
+ tt.byName(env, t)
+ if tt.byID != nil {
+ tt.byID(env, t)
+ }
+
+ got, err := env.cs.InstanceExists(context.TODO(),
&corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}})
+
+ if got != tt.want {
+ t.Errorf("InstanceExists() = %v, want %v", got,
tt.want)
+ }
+ if tt.wantErr != nil && err != tt.wantErr {
+ t.Errorf("InstanceExists() error = %v, want
%v", err, tt.wantErr)
+ }
+ if tt.errContains != "" && (err == nil ||
!strings.Contains(err.Error(), tt.errContains)) {
+ t.Errorf("InstanceExists() error = %v, want it
to contain %q", err, tt.errContains)
+ }
+ })
+ }
+}
+
+// TestInstanceMetadata checks the metadata the node controller sets on a new
node and that a
+// failure of any of the four lookups behind it is returned instead of a
half-filled result.
+func TestInstanceMetadata(t *testing.T) {
+ node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-1"}}
+ lookups := []string{"InstanceID", "InstanceType", "NodeAddresses",
"GetZoneByNodeName"}
+
+ t.Run("all lookups succeed", func(t *testing.T) {
+ env := newVMLookupEnv(t, testProjectID)
+ env.cs.region = "region-1"
+ env.vm.EXPECT().GetVirtualMachineByName(gomock.Any(),
gomock.Any()).Times(len(lookups)).
+ DoAndReturn(func(name string, opts
...cloudstack.OptionFunc) (*cloudstack.VirtualMachine, int, error) {
+ if name != "node-1" || appliedProjectID(t,
opts) != testProjectID {
+ t.Errorf("lookup of %q in project %q,
want node-1 in %q", name, appliedProjectID(t, opts), testProjectID)
+ }
+ return testInstance(), 1, nil
+ })
+
+ got, err := env.cs.InstanceMetadata(context.TODO(), node)
+ if err != nil {
+ t.Fatalf("InstanceMetadata() unexpected error: %v", err)
+ }
+
+ want := &cloudprovider.InstanceMetadata{
+ ProviderID: "external-cloudstack://vm-1",
+ InstanceType: "SmallInstance",
+ NodeAddresses: []corev1.NodeAddress{{Type:
corev1.NodeInternalIP, Address: "10.0.0.1"}},
+ Zone: "zone-a",
+ Region: "region-1",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("InstanceMetadata() = %+v, want %+v", got,
want)
+ }
+ })
+
+ for failAt, lookup := range lookups {
+ t.Run("failure in "+lookup, func(t *testing.T) {
+ env := newVMLookupEnv(t, "")
+ calls := 0
+ env.vm.EXPECT().GetVirtualMachineByName(gomock.Any(),
gomock.Any()).Times(failAt + 1).
+ DoAndReturn(func(string,
...cloudstack.OptionFunc) (*cloudstack.VirtualMachine, int, error) {
+ calls++
+ if calls == failAt+1 {
+ return nil, 1,
errors.New("boom")
+ }
+ return testInstance(), 1, nil
+ })
+
+ got, err := env.cs.InstanceMetadata(context.TODO(),
node)
+
+ if got != nil || err == nil ||
!strings.Contains(err.Error(), "boom") {
+ t.Errorf("InstanceMetadata() = %+v, %v, want
nil and an error containing boom", got, err)
+ }
+ })
+ }
+}
+
+// TestUnsupportedInstanceOperations checks the operations the provider leaves
to Kubernetes.
+func TestUnsupportedInstanceOperations(t *testing.T) {
+ cs := &CSCloud{}
+ node := &corev1.Node{}
+
+ if shutdown, err := cs.InstanceShutdownByProviderID(context.TODO(),
"vm-1"); shutdown || err != cloudprovider.NotImplemented {
+ t.Errorf("InstanceShutdownByProviderID() = %v, %v, want false,
NotImplemented", shutdown, err)
+ }
+ if shutdown, err := cs.InstanceShutdown(context.TODO(), node); shutdown
|| err != cloudprovider.NotImplemented {
+ t.Errorf("InstanceShutdown() = %v, %v, want false,
NotImplemented", shutdown, err)
+ }
+ if err := cs.AddSSHKeyToAllInstances(context.TODO(), "user", nil); err
!= cloudprovider.NotImplemented {
+ t.Errorf("AddSSHKeyToAllInstances() = %v, want NotImplemented",
err)
+ }
+ if name, err := cs.CurrentNodeName(context.TODO(), "host-1"); err !=
nil || name != "host-1" {
+ t.Errorf("CurrentNodeName() = %q, %v, want host-1", name, err)
+ }
+}
diff --git a/cloudstack_zone_test.go b/cloudstack_zone_test.go
new file mode 100644
index 00000000..77f5290d
--- /dev/null
+++ b/cloudstack_zone_test.go
@@ -0,0 +1,279 @@
+/*
+ * 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"
+ "strings"
+ "testing"
+
+ "github.com/apache/cloudstack-go/v2/cloudstack"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/kubernetes/fake"
+ restclient "k8s.io/client-go/rest"
+ cloudprovider "k8s.io/cloud-provider"
+)
+
+type fakeClientBuilder struct {
+ client kubernetes.Interface
+ err error
+}
+
+func (b *fakeClientBuilder) Config(string) (*restclient.Config, error) {
return nil, b.err }
+func (b *fakeClientBuilder) ConfigOrDie(string) *restclient.Config {
return nil }
+func (b *fakeClientBuilder) Client(string) (kubernetes.Interface, error) {
+ return b.client, b.err
+}
+func (b *fakeClientBuilder) ClientOrDie(string) kubernetes.Interface { return
b.client }
+
+var _ cloudprovider.ControllerClientBuilder = &fakeClientBuilder{}
+
+type zoneLookup struct {
+ name string
+ call func(cs *CSCloud) (cloudprovider.Zone, error)
+ // expect registers the one CloudStack lookup the call makes.
+ expect func(e *vmLookupEnv, t *testing.T, result
*cloudstack.VirtualMachine, count int, err error)
+ // notFound is the text of the error for an instance CloudStack does
not know.
+ notFound string
+}
+
+var zoneLookups = []zoneLookup{
+ {
+ name: "GetZoneByNodeName",
+ call: func(cs *CSCloud) (cloudprovider.Zone, error) {
+ return cs.GetZoneByNodeName(context.TODO(), "node-1")
+ },
+ expect: func(e *vmLookupEnv, t *testing.T, result
*cloudstack.VirtualMachine, count int, err error) {
+ e.expectByName(t, "node-1", result, count, err)
+ },
+ notFound: "could not find node: node-1",
+ },
+ {
+ name: "GetZoneByProviderID",
+ call: func(cs *CSCloud) (cloudprovider.Zone, error) {
+ return cs.GetZoneByProviderID(context.TODO(),
"external-cloudstack://vm-1")
+ },
+ expect: func(e *vmLookupEnv, t *testing.T, result
*cloudstack.VirtualMachine, count int, err error) {
+ e.expectByID(t, "vm-1", result, count, err)
+ },
+ notFound: "could not find node by ID:
external-cloudstack://vm-1",
+ },
+}
+
+// TestZoneLookups checks that a zone is resolved from the instance's zone
name in the configured
+// project, that the region is the configured one or else the zone name, and
that failures read
+// differently for a missing instance and an API error.
+func TestZoneLookups(t *testing.T) {
+ for _, lookup := range zoneLookups {
+ t.Run(lookup.name+"/region falls back to the zone", func(t
*testing.T) {
+ env := newVMLookupEnv(t, "")
+ lookup.expect(env, t, testInstance(), 1, nil)
+
+ zone, err := lookup.call(env.cs)
+
+ if err != nil || zone.FailureDomain != "zone-a" ||
zone.Region != "zone-a" {
+ t.Errorf("%s() = %+v, %v, want zone-a in region
zone-a", lookup.name, zone, err)
+ }
+ })
+
+ t.Run(lookup.name+"/configured region in a project", func(t
*testing.T) {
+ env := newVMLookupEnv(t, testProjectID)
+ env.cs.region = "region-1"
+ lookup.expect(env, t, testInstance(), 1, nil)
+
+ zone, err := lookup.call(env.cs)
+
+ if err != nil || zone.FailureDomain != "zone-a" ||
zone.Region != "region-1" {
+ t.Errorf("%s() = %+v, %v, want zone-a in region
region-1", lookup.name, zone, err)
+ }
+ })
+
+ t.Run(lookup.name+"/instance not found", func(t *testing.T) {
+ env := newVMLookupEnv(t, "")
+ lookup.expect(env, t, nil, 0, errors.New("no match"))
+
+ _, err := lookup.call(env.cs)
+
+ if err == nil || err.Error() != lookup.notFound {
+ t.Errorf("%s() error = %v, want %q",
lookup.name, err, lookup.notFound)
+ }
+ })
+
+ t.Run(lookup.name+"/api error", func(t *testing.T) {
+ env := newVMLookupEnv(t, "")
+ lookup.expect(env, t, nil, 1, errors.New("boom"))
+
+ _, err := lookup.call(env.cs)
+
+ if err == nil || err.Error() != "error retrieving zone:
boom" {
+ t.Errorf("%s() error = %v, want %q",
lookup.name, err, "error retrieving zone: boom")
+ }
+ })
+ }
+}
+
+func podNamed(name, namespace, nodeName string) *corev1.Pod {
+ return &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
+ Spec: corev1.PodSpec{NodeName: nodeName},
+ }
+}
+
+func setPodEnvironment(t *testing.T, podName, namespace string) {
+ t.Helper()
+ t.Setenv("HOSTNAME", podName)
+ t.Setenv("POD_NAMESPACE", namespace)
+}
+
+// TestGetNodeNameFromPod checks that the node is read from the CCM's own pod
and that every way
+// that can fail is reported.
+func TestGetNodeNameFromPod(t *testing.T) {
+ setPodEnvironment(t, "ccm-pod", "ccm-ns")
+
+ tests := []struct {
+ name string
+ builder *fakeClientBuilder
+ want string
+ errPrefix string
+ }{
+ {
+ name: "no client builder",
+ errPrefix: "clientBuilder not initialized",
+ },
+ {
+ name: "client cannot be built",
+ builder: &fakeClientBuilder{err: errors.New("no
config")},
+ errPrefix: "failed to get Kubernetes client: no config",
+ },
+ {
+ name: "pod does not exist",
+ builder: &fakeClientBuilder{client:
fake.NewSimpleClientset()},
+ errPrefix: "failed to get pod ccm-ns/ccm-pod from
Kubernetes API",
+ },
+ {
+ name: "pod not scheduled yet",
+ builder: &fakeClientBuilder{client:
fake.NewSimpleClientset(podNamed("ccm-pod", "ccm-ns", ""))},
+ errPrefix: "pod ccm-ns/ccm-pod does not have a nodeName
assigned yet",
+ },
+ {
+ name: "pod scheduled",
+ builder: &fakeClientBuilder{client:
fake.NewSimpleClientset(podNamed("ccm-pod", "ccm-ns", "node-1"))},
+ want: "node-1",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cs := &CSCloud{}
+ if tt.builder != nil {
+ cs.clientBuilder = tt.builder
+ }
+
+ got, err := cs.getNodeNameFromPod(context.TODO())
+
+ if tt.errPrefix != "" {
+ if err == nil ||
!strings.HasPrefix(err.Error(), tt.errPrefix) {
+ t.Errorf("getNodeNameFromPod() error =
%v, want prefix %q", err, tt.errPrefix)
+ }
+ return
+ }
+ if err != nil || got != tt.want {
+ t.Errorf("getNodeNameFromPod() = %q, %v, want
%q", got, err, tt.want)
+ }
+ })
+ }
+}
+
+// TestGetZone checks that the zone is looked up once through the pod's node
and then served from the cache.
+func TestGetZone(t *testing.T) {
+ t.Run("cached zone makes no lookup", func(t *testing.T) {
+ env := newVMLookupEnv(t, "")
+ env.cs.zone = "zone-a"
+ env.cs.region = "region-1"
+
+ zone, err := env.cs.GetZone(context.TODO())
+
+ if err != nil || zone.FailureDomain != "zone-a" || zone.Region
!= "region-1" {
+ t.Errorf("GetZone() = %+v, %v, want zone-a in region
region-1", zone, err)
+ }
+ })
+
+ t.Run("zone of the pod's node is looked up once", func(t *testing.T) {
+ setPodEnvironment(t, "ccm-pod", "ccm-ns")
+ env := newVMLookupEnv(t, testProjectID)
+ env.cs.clientBuilder = &fakeClientBuilder{client:
fake.NewSimpleClientset(podNamed("ccm-pod", "ccm-ns", "node-1"))}
+ env.expectByName(t, "node-1", testInstance(), 1, nil).Times(1)
+
+ for i := 0; i < 2; i++ {
+ zone, err := env.cs.GetZone(context.TODO())
+ if err != nil || zone.FailureDomain != "zone-a" ||
zone.Region != "zone-a" {
+ t.Fatalf("GetZone() call %d = %+v, %v, want
zone-a in region zone-a", i+1, zone, err)
+ }
+ }
+ })
+
+ failures := []struct {
+ name string
+ builder cloudprovider.ControllerClientBuilder
+ result *cloudstack.VirtualMachine
+ count int
+ err error
+ errPrefix string
+ }{
+ {
+ name: "node of the pod unknown",
+ errPrefix: "failed to get node name for retrieving the
zone",
+ },
+ {
+ name: "instance not found",
+ builder: &fakeClientBuilder{client:
fake.NewSimpleClientset(podNamed("ccm-pod", "ccm-ns", "node-1"))},
+ count: 0,
+ err: errors.New("no match"),
+ errPrefix: "could not find CloudStack instance with
name node-1 for retrieving the zone",
+ },
+ {
+ name: "api error",
+ builder: &fakeClientBuilder{client:
fake.NewSimpleClientset(podNamed("ccm-pod", "ccm-ns", "node-1"))},
+ count: 1,
+ err: errors.New("boom"),
+ errPrefix: "error getting instance for retrieving the
zone",
+ },
+ }
+
+ for _, tt := range failures {
+ t.Run(tt.name, func(t *testing.T) {
+ setPodEnvironment(t, "ccm-pod", "ccm-ns")
+ env := newVMLookupEnv(t, "")
+ env.cs.clientBuilder = tt.builder
+ if tt.err != nil {
+ env.expectByName(t, "node-1", tt.result,
tt.count, tt.err)
+ }
+
+ _, err := env.cs.GetZone(context.TODO())
+
+ if err == nil || !strings.HasPrefix(err.Error(),
tt.errPrefix) {
+ t.Errorf("GetZone() error = %v, want prefix
%q", err, tt.errPrefix)
+ }
+ })
+ }
+}