This is an automated email from the ASF dual-hosted git repository. vishesh pushed a commit to branch add-unit-tests in repository https://gitbox.apache.org/repos/asf/cloudstack-kubernetes-provider.git
commit 1bc4513e5c4448e281ba8965728c7745c25243f4 Author: vishesh92 <[email protected]> AuthorDate: Fri Dec 5 16:10:27 2025 +0530 Add some unit tests --- cloudstack_instances_test.go | 176 +++++++++++++++ cloudstack_loadbalancer_test.go | 480 ++++++++++++++++++++++++++++++++++++++++ protocol_test.go | 259 ++++++++++++++++++++++ 3 files changed, 915 insertions(+) diff --git a/cloudstack_instances_test.go b/cloudstack_instances_test.go new file mode 100644 index 00000000..4210e305 --- /dev/null +++ b/cloudstack_instances_test.go @@ -0,0 +1,176 @@ +/* + * 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 ( + "strings" + "testing" + + "github.com/apache/cloudstack-go/v2/cloudstack" + corev1 "k8s.io/api/core/v1" +) + +func TestNodeAddresses(t *testing.T) { + cs := &CSCloud{} + + tests := []struct { + name string + instance *cloudstack.VirtualMachine + wantAddrs []corev1.NodeAddress + wantErr bool + errContains string + }{ + { + name: "instance with internal IP only", + instance: &cloudstack.VirtualMachine{ + Id: "vm-1", + Name: "test-vm", + Nic: []cloudstack.Nic{ + {Ipaddress: "10.0.0.1"}, + }, + }, + wantAddrs: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + wantErr: false, + }, + { + name: "instance with internal IP and hostname", + instance: &cloudstack.VirtualMachine{ + Id: "vm-1", + Name: "test-vm", + Hostname: "test-hostname", + Nic: []cloudstack.Nic{ + {Ipaddress: "10.0.0.1"}, + }, + }, + wantAddrs: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + {Type: corev1.NodeHostName, Address: "test-hostname"}, + }, + wantErr: false, + }, + { + name: "instance with internal IP and public IP", + instance: &cloudstack.VirtualMachine{ + Id: "vm-1", + Name: "test-vm", + Publicip: "203.0.113.1", + Nic: []cloudstack.Nic{ + {Ipaddress: "10.0.0.1"}, + }, + }, + wantAddrs: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + {Type: corev1.NodeExternalIP, Address: "203.0.113.1"}, + }, + wantErr: false, + }, + { + name: "instance with all address types", + instance: &cloudstack.VirtualMachine{ + Id: "vm-1", + Name: "test-vm", + Hostname: "test-hostname", + Publicip: "203.0.113.1", + Nic: []cloudstack.Nic{ + {Ipaddress: "10.0.0.1"}, + }, + }, + wantAddrs: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + {Type: corev1.NodeHostName, Address: "test-hostname"}, + {Type: corev1.NodeExternalIP, Address: "203.0.113.1"}, + }, + wantErr: false, + }, + { + name: "instance with no NICs returns error", + instance: &cloudstack.VirtualMachine{ + Id: "vm-1", + Name: "test-vm", + Nic: []cloudstack.Nic{}, + }, + wantAddrs: nil, + wantErr: true, + errContains: "does not have an internal IP", + }, + { + name: "instance with nil NICs returns error", + instance: &cloudstack.VirtualMachine{ + Id: "vm-1", + Name: "test-vm", + Nic: nil, + }, + wantAddrs: nil, + wantErr: true, + errContains: "does not have an internal IP", + }, + { + name: "instance with multiple NICs uses first", + instance: &cloudstack.VirtualMachine{ + Id: "vm-1", + Name: "test-vm", + Nic: []cloudstack.Nic{ + {Ipaddress: "10.0.0.1"}, + {Ipaddress: "10.0.0.2"}, + }, + }, + wantAddrs: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAddrs, err := cs.nodeAddresses(tt.instance) + + if tt.wantErr { + if err == nil { + t.Errorf("nodeAddresses() expected error, got nil") + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("nodeAddresses() error = %v, want error containing %q", err, tt.errContains) + } + return + } + + if err != nil { + t.Errorf("nodeAddresses() unexpected error: %v", err) + return + } + + if len(gotAddrs) != len(tt.wantAddrs) { + t.Errorf("nodeAddresses() returned %d addresses, want %d", len(gotAddrs), len(tt.wantAddrs)) + return + } + + for i, want := range tt.wantAddrs { + if gotAddrs[i].Type != want.Type || gotAddrs[i].Address != want.Address { + t.Errorf("nodeAddresses()[%d] = {%v, %v}, want {%v, %v}", + i, gotAddrs[i].Type, gotAddrs[i].Address, want.Type, want.Address) + } + } + }) + } +} diff --git a/cloudstack_loadbalancer_test.go b/cloudstack_loadbalancer_test.go new file mode 100644 index 00000000..bbd63066 --- /dev/null +++ b/cloudstack_loadbalancer_test.go @@ -0,0 +1,480 @@ +/* + * 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 ( + "sort" + "testing" + + "github.com/apache/cloudstack-go/v2/cloudstack" + 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"}, + }, + { + 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) + } + }) + } +} diff --git a/protocol_test.go b/protocol_test.go new file mode 100644 index 00000000..84ff78a3 --- /dev/null +++ b/protocol_test.go @@ -0,0 +1,259 @@ +/* + * 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 ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestLoadBalancerProtocol_CSProtocol(t *testing.T) { + tests := []struct { + name string + protocol LoadBalancerProtocol + want string + }{ + { + name: "TCP protocol", + protocol: LoadBalancerProtocolTCP, + want: "tcp", + }, + { + name: "UDP protocol", + protocol: LoadBalancerProtocolUDP, + want: "udp", + }, + { + name: "TCP Proxy protocol", + protocol: LoadBalancerProtocolTCPProxy, + want: "tcp-proxy", + }, + { + name: "Invalid protocol", + protocol: LoadBalancerProtocolInvalid, + want: "", + }, + { + name: "Unknown protocol value", + protocol: LoadBalancerProtocol(999), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.protocol.CSProtocol(); got != tt.want { + t.Errorf("CSProtocol() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoadBalancerProtocol_IPProtocol(t *testing.T) { + tests := []struct { + name string + protocol LoadBalancerProtocol + want string + }{ + { + name: "TCP protocol maps to tcp", + protocol: LoadBalancerProtocolTCP, + want: "tcp", + }, + { + name: "TCP Proxy protocol also maps to tcp", + protocol: LoadBalancerProtocolTCPProxy, + want: "tcp", + }, + { + name: "UDP protocol maps to udp", + protocol: LoadBalancerProtocolUDP, + want: "udp", + }, + { + name: "Invalid protocol returns empty", + protocol: LoadBalancerProtocolInvalid, + want: "", + }, + { + name: "Unknown protocol value returns empty", + protocol: LoadBalancerProtocol(999), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.protocol.IPProtocol(); got != tt.want { + t.Errorf("IPProtocol() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoadBalancerProtocol_String(t *testing.T) { + // String() should return the same as CSProtocol() + protocols := []LoadBalancerProtocol{ + LoadBalancerProtocolTCP, + LoadBalancerProtocolUDP, + LoadBalancerProtocolTCPProxy, + LoadBalancerProtocolInvalid, + } + + for _, p := range protocols { + if got, want := p.String(), p.CSProtocol(); got != want { + t.Errorf("String() = %v, want %v (same as CSProtocol)", got, want) + } + } +} + +func TestProtocolFromLoadBalancer(t *testing.T) { + tests := []struct { + name string + protocol string + want LoadBalancerProtocol + }{ + { + name: "tcp string", + protocol: "tcp", + want: LoadBalancerProtocolTCP, + }, + { + name: "udp string", + protocol: "udp", + want: LoadBalancerProtocolUDP, + }, + { + name: "tcp-proxy string", + protocol: "tcp-proxy", + want: LoadBalancerProtocolTCPProxy, + }, + { + name: "empty string returns invalid", + protocol: "", + want: LoadBalancerProtocolInvalid, + }, + { + name: "unknown protocol returns invalid", + protocol: "icmp", + want: LoadBalancerProtocolInvalid, + }, + { + name: "uppercase TCP returns invalid", + protocol: "TCP", + want: LoadBalancerProtocolInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ProtocolFromLoadBalancer(tt.protocol); got != tt.want { + t.Errorf("ProtocolFromLoadBalancer(%q) = %v, want %v", tt.protocol, got, tt.want) + } + }) + } +} + +func TestProtocolFromServicePort(t *testing.T) { + tests := []struct { + name string + port corev1.ServicePort + annotations map[string]string + want LoadBalancerProtocol + }{ + { + name: "TCP port without proxy annotation", + port: corev1.ServicePort{ + Protocol: corev1.ProtocolTCP, + Port: 80, + }, + annotations: nil, + want: LoadBalancerProtocolTCP, + }, + { + name: "TCP port with proxy annotation true", + port: corev1.ServicePort{ + Protocol: corev1.ProtocolTCP, + Port: 80, + }, + annotations: map[string]string{ + ServiceAnnotationLoadBalancerProxyProtocol: "true", + }, + want: LoadBalancerProtocolTCPProxy, + }, + { + name: "TCP port with proxy annotation false", + port: corev1.ServicePort{ + Protocol: corev1.ProtocolTCP, + Port: 80, + }, + annotations: map[string]string{ + ServiceAnnotationLoadBalancerProxyProtocol: "false", + }, + want: LoadBalancerProtocolTCP, + }, + { + name: "UDP port", + port: corev1.ServicePort{ + Protocol: corev1.ProtocolUDP, + Port: 53, + }, + annotations: nil, + want: LoadBalancerProtocolUDP, + }, + { + name: "UDP port ignores proxy annotation", + port: corev1.ServicePort{ + Protocol: corev1.ProtocolUDP, + Port: 53, + }, + annotations: map[string]string{ + ServiceAnnotationLoadBalancerProxyProtocol: "true", + }, + want: LoadBalancerProtocolUDP, + }, + { + name: "SCTP port returns invalid", + port: corev1.ServicePort{ + Protocol: corev1.ProtocolSCTP, + Port: 80, + }, + annotations: nil, + want: LoadBalancerProtocolInvalid, + }, + } + + 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 := ProtocolFromServicePort(tt.port, service); got != tt.want { + t.Errorf("ProtocolFromServicePort() = %v, want %v", got, tt.want) + } + }) + } +}
