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 0874e77d50eedf2248c9409a546088376447ceae Author: vishesh92 <[email protected]> AuthorDate: Mon Dec 15 13:05:47 2025 +0530 add some unit tests --- cloudstack_instances_test.go | 75 + cloudstack_loadbalancer_test.go | 2924 +++++++++++++++++++++++++++++++++++++++ cloudstack_test.go | 40 + 3 files changed, 3039 insertions(+) diff --git a/cloudstack_instances_test.go b/cloudstack_instances_test.go index 4210e305..791d5617 100644 --- a/cloudstack_instances_test.go +++ b/cloudstack_instances_test.go @@ -174,3 +174,78 @@ func TestNodeAddresses(t *testing.T) { }) } } + +func TestGetProviderIDFromInstanceID(t *testing.T) { + cs := &CSCloud{} + + tests := []struct { + name string + instanceID string + want string + }{ + { + name: "valid instance ID", + instanceID: "vm-123", + want: "external-cloudstack://vm-123", + }, + { + name: "empty instance ID", + instanceID: "", + want: "external-cloudstack://", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cs.getProviderIDFromInstanceID(tt.instanceID) + if got != tt.want { + t.Errorf("getProviderIDFromInstanceID(%q) = %q, want %q", tt.instanceID, got, tt.want) + } + }) + } +} + +func TestGetInstanceIDFromProviderID(t *testing.T) { + cs := &CSCloud{} + + tests := []struct { + name string + providerID string + want string + }{ + { + name: "full provider ID format", + providerID: "external-cloudstack://vm-123", + want: "vm-123", + }, + { + name: "instance ID only - backward compatibility", + providerID: "vm-123", + want: "vm-123", + }, + { + name: "empty string", + providerID: "", + want: "", + }, + { + name: "invalid format - no separator", + providerID: "external-cloudstack-vm-123", + want: "external-cloudstack-vm-123", + }, + { + name: "different provider prefix", + providerID: "aws://i-1234567890abcdef0", + want: "i-1234567890abcdef0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cs.getInstanceIDFromProviderID(tt.providerID) + if got != tt.want { + t.Errorf("getInstanceIDFromProviderID(%q) = %q, want %q", tt.providerID, got, tt.want) + } + }) + } +} diff --git a/cloudstack_loadbalancer_test.go b/cloudstack_loadbalancer_test.go index 847361a0..b863fc17 100644 --- a/cloudstack_loadbalancer_test.go +++ b/cloudstack_loadbalancer_test.go @@ -20,6 +20,7 @@ package cloudstack import ( + "fmt" "reflect" "sort" "strings" @@ -764,3 +765,2926 @@ func TestCheckLoadBalancerRule(t *testing.T) { } }) } + +func TestRuleToString(t *testing.T) { + tests := []struct { + name string + rule *cloudstack.FirewallRule + want string + }{ + { + name: "TCP rule", + rule: &cloudstack.FirewallRule{ + Protocol: "tcp", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.1", + Startport: 80, + Endport: 80, + }, + want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}", + }, + { + name: "UDP rule", + rule: &cloudstack.FirewallRule{ + Protocol: "udp", + Cidrlist: "192.168.0.0/16", + Ipaddress: "203.0.113.2", + Startport: 53, + Endport: 53, + }, + want: "{[192.168.0.0/16] -> 203.0.113.2:[53-53] (udp)}", + }, + { + name: "TCP rule with port range", + rule: &cloudstack.FirewallRule{ + Protocol: "tcp", + Cidrlist: "0.0.0.0/0", + Ipaddress: "203.0.113.3", + Startport: 8000, + Endport: 8999, + }, + want: "{[0.0.0.0/0] -> 203.0.113.3:[8000-8999] (tcp)}", + }, + { + name: "ICMP rule", + rule: &cloudstack.FirewallRule{ + Protocol: "icmp", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.4", + Icmptype: 8, + Icmpcode: 0, + }, + want: "{[10.0.0.0/8] -> 203.0.113.4 [8,0] (icmp)}", + }, + { + name: "unknown protocol", + rule: &cloudstack.FirewallRule{ + Protocol: "gre", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.6", + }, + want: "{[10.0.0.0/8] -> 203.0.113.6 (gre)}", + }, + { + name: "nil rule", + rule: nil, + want: "nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ruleToString(tt.rule) + if got != tt.want { + t.Errorf("ruleToString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRulesToString(t *testing.T) { + tests := []struct { + name string + rules []*cloudstack.FirewallRule + want string + }{ + { + name: "empty list", + rules: []*cloudstack.FirewallRule{}, + want: "", + }, + { + name: "single rule", + rules: []*cloudstack.FirewallRule{ + { + Protocol: "tcp", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.1", + Startport: 80, + Endport: 80, + }, + }, + want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}", + }, + { + name: "multiple rules", + rules: []*cloudstack.FirewallRule{ + { + Protocol: "tcp", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.1", + Startport: 80, + Endport: 80, + }, + { + Protocol: "udp", + Cidrlist: "192.168.0.0/16", + Ipaddress: "203.0.113.2", + Startport: 53, + Endport: 53, + }, + { + Protocol: "icmp", + Cidrlist: "0.0.0.0/0", + Ipaddress: "203.0.113.3", + Icmptype: 8, + Icmpcode: 0, + }, + }, + want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}, {[192.168.0.0/16] -> 203.0.113.2:[53-53] (udp)}, {[0.0.0.0/0] -> 203.0.113.3 [8,0] (icmp)}", + }, + { + name: "rules with nil rule", + rules: []*cloudstack.FirewallRule{ + { + Protocol: "tcp", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.1", + Startport: 80, + Endport: 80, + }, + nil, + { + Protocol: "udp", + Cidrlist: "192.168.0.0/16", + Ipaddress: "203.0.113.2", + Startport: 53, + Endport: 53, + }, + }, + want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}, nil, {[192.168.0.0/16] -> 203.0.113.2:[53-53] (udp)}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rulesToString(tt.rules) + if got != tt.want { + t.Errorf("rulesToString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRulesMapToString(t *testing.T) { + tests := []struct { + name string + rules map[*cloudstack.FirewallRule]bool + want string + }{ + { + name: "empty map", + rules: map[*cloudstack.FirewallRule]bool{}, + want: "", + }, + { + name: "single rule", + rules: map[*cloudstack.FirewallRule]bool{ + { + Protocol: "tcp", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.1", + Startport: 80, + Endport: 80, + }: true, + }, + want: "{[10.0.0.0/8] -> 203.0.113.1:[80-80] (tcp)}", + }, + { + name: "multiple rules", + rules: map[*cloudstack.FirewallRule]bool{ + { + Protocol: "tcp", + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.1", + Startport: 80, + Endport: 80, + }: true, + { + Protocol: "udp", + Cidrlist: "192.168.0.0/16", + Ipaddress: "203.0.113.2", + Startport: 53, + Endport: 53, + }: false, + { + Protocol: "icmp", + Cidrlist: "0.0.0.0/0", + Ipaddress: "203.0.113.3", + Icmptype: 8, + Icmpcode: 0, + }: true, + }, + // Note: Map iteration order is non-deterministic, so we need to check + // that all rules are present, not the exact order + want: "", // We'll check this differently + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rulesMapToString(tt.rules) + + if tt.want == "" { + // For maps, we can't predict order, so check that all rules are present + if len(tt.rules) == 0 { + if got != "" { + t.Errorf("rulesMapToString() = %q, want empty string", got) + } + return + } + + // Check that all rules are present in the output + expectedRules := make([]string, 0, len(tt.rules)) + for rule := range tt.rules { + expectedRules = append(expectedRules, ruleToString(rule)) + } + + // Split the output and check each rule is present + if got != "" { + parts := strings.Split(got, ", ") + if len(parts) != len(expectedRules) { + t.Errorf("rulesMapToString() returned %d rules, want %d", len(parts), len(expectedRules)) + return + } + + // Check that all expected rules are in the output + for _, expectedRule := range expectedRules { + found := false + for _, part := range parts { + if part == expectedRule { + found = true + break + } + } + if !found { + t.Errorf("rulesMapToString() missing rule %q in output %q", expectedRule, got) + } + } + } else if len(expectedRules) > 0 { + t.Errorf("rulesMapToString() = empty string, want rules to be present") + } + } else { + if got != tt.want { + t.Errorf("rulesMapToString() = %q, want %q", got, tt.want) + } + } + }) + } +} + +func TestGetPublicIPAddress(t *testing.T) { + t.Run("IP found and allocated", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + resp := &cloudstack.ListPublicIpAddressesResponse{ + Count: 1, + PublicIpAddresses: []*cloudstack.PublicIpAddress{ + { + Id: "ip-123", + Ipaddress: "203.0.113.1", + Allocated: "2023-01-01T00:00:00+0000", + }, + }, + } + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + } + + err := lb.getPublicIPAddress("203.0.113.1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lb.ipAddr != "203.0.113.1" { + t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1") + } + if lb.ipAddrID != "ip-123" { + t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123") + } + }) + + t.Run("IP found but not allocated - associates", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + resp := &cloudstack.ListPublicIpAddressesResponse{ + Count: 1, + PublicIpAddresses: []*cloudstack.PublicIpAddress{ + { + Id: "ip-123", + Ipaddress: "203.0.113.1", + Allocated: "", + }, + }, + } + + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + associateResp := &cloudstack.AssociateIpAddressResponse{ + Id: "ip-123", + Ipaddress: "203.0.113.1", + } + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil), + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + ipAddr: "203.0.113.1", + } + + err := lb.getPublicIPAddress("203.0.113.1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("IP not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + resp := &cloudstack.ListPublicIpAddressesResponse{ + Count: 0, + PublicIpAddresses: []*cloudstack.PublicIpAddress{}, + } + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + } + + err := lb.getPublicIPAddress("203.0.113.1") + if err == nil { + t.Fatalf("expected error for IP not found") + } + if !strings.Contains(err.Error(), "could not find IP address") { + t.Errorf("error message = %q, want to contain 'could not find IP address'", err.Error()) + } + }) + + t.Run("multiple IPs found", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + resp := &cloudstack.ListPublicIpAddressesResponse{ + Count: 2, + PublicIpAddresses: []*cloudstack.PublicIpAddress{ + {Id: "ip-1", Ipaddress: "203.0.113.1"}, + {Id: "ip-2", Ipaddress: "203.0.113.1"}, + }, + } + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + } + + err := lb.getPublicIPAddress("203.0.113.1") + if err == nil { + t.Fatalf("expected error for multiple IPs found") + } + if !strings.Contains(err.Error(), "Found 2 addresses") { + t.Errorf("error message = %q, want to contain 'Found 2 addresses'", err.Error()) + } + }) + + t.Run("error retrieving IP", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + apiErr := fmt.Errorf("API error") + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + } + + err := lb.getPublicIPAddress("203.0.113.1") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error retrieving IP address") { + t.Errorf("error message = %q, want to contain 'error retrieving IP address'", err.Error()) + } + }) + + t.Run("project ID handling", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + resp := &cloudstack.ListPublicIpAddressesResponse{ + Count: 1, + PublicIpAddresses: []*cloudstack.PublicIpAddress{ + { + Id: "ip-123", + Ipaddress: "203.0.113.1", + Allocated: "2023-01-01T00:00:00+0000", + }, + }, + } + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + projectID: "proj-123", + } + + err := lb.getPublicIPAddress("203.0.113.1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestAssociatePublicIPAddress(t *testing.T) { + t.Run("associate IP for regular network", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + associateResp := &cloudstack.AssociateIpAddressResponse{ + Id: "ip-123", + Ipaddress: "203.0.113.1", + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + } + + err := lb.associatePublicIPAddress() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lb.ipAddr != "203.0.113.1" { + t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1") + } + if lb.ipAddrID != "ip-123" { + t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123") + } + if !lb.ipAssociatedByController { + t.Errorf("ipAssociatedByController = false, want true") + } + }) + + t.Run("associate IP for VPC network", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "vpc-456", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + associateResp := &cloudstack.AssociateIpAddressResponse{ + Id: "ip-123", + Ipaddress: "203.0.113.1", + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + } + + err := lb.associatePublicIPAddress() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("associate specific IP address", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + associateResp := &cloudstack.AssociateIpAddressResponse{ + Id: "ip-123", + Ipaddress: "203.0.113.2", + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + ipAddr: "203.0.113.2", + } + + err := lb.associatePublicIPAddress() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("error retrieving network", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + apiErr := fmt.Errorf("network API error") + + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(nil, 1, apiErr) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + }, + networkID: "net-123", + } + + err := lb.associatePublicIPAddress() + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error retrieving network") { + t.Errorf("error message = %q, want to contain 'error retrieving network'", err.Error()) + } + }) + + t.Run("network not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(nil, 0, fmt.Errorf("not found")) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + }, + networkID: "net-123", + } + + err := lb.associatePublicIPAddress() + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "could not find network") { + t.Errorf("error message = %q, want to contain 'could not find network'", err.Error()) + } + }) + + t.Run("error associating IP", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + apiErr := fmt.Errorf("associate API error") + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + } + + err := lb.associatePublicIPAddress() + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error associating new IP address") { + t.Errorf("error message = %q, want to contain 'error associating new IP address'", err.Error()) + } + }) + + t.Run("project ID handling", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + associateResp := &cloudstack.AssociateIpAddressResponse{ + Id: "ip-123", + Ipaddress: "203.0.113.1", + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + projectID: "proj-123", + } + + err := lb.associatePublicIPAddress() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestReleaseLoadBalancerIP(t *testing.T) { + t.Run("successful release", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + disassociateParams := &cloudstack.DisassociateIpAddressParams{} + + gomock.InOrder( + mockAddress.EXPECT().NewDisassociateIpAddressParams("ip-123").Return(disassociateParams), + mockAddress.EXPECT().DisassociateIpAddress(disassociateParams).Return(&cloudstack.DisassociateIpAddressResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + ipAddrID: "ip-123", + ipAddr: "203.0.113.1", + } + + err := lb.releaseLoadBalancerIP() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("error releasing IP", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + disassociateParams := &cloudstack.DisassociateIpAddressParams{} + apiErr := fmt.Errorf("disassociate API error") + + gomock.InOrder( + mockAddress.EXPECT().NewDisassociateIpAddressParams("ip-123").Return(disassociateParams), + mockAddress.EXPECT().DisassociateIpAddress(disassociateParams).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + ipAddrID: "ip-123", + ipAddr: "203.0.113.1", + } + + err := lb.releaseLoadBalancerIP() + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error releasing load balancer IP") { + t.Errorf("error message = %q, want to contain 'error releasing load balancer IP'", err.Error()) + } + }) +} + +func TestGetLoadBalancerIP(t *testing.T) { + t.Run("IP specified - retrieve existing", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + resp := &cloudstack.ListPublicIpAddressesResponse{ + Count: 1, + PublicIpAddresses: []*cloudstack.PublicIpAddress{ + { + Id: "ip-123", + Ipaddress: "203.0.113.1", + Allocated: "2023-01-01T00:00:00+0000", + }, + }, + } + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + } + + err := lb.getLoadBalancerIP("203.0.113.1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lb.ipAddr != "203.0.113.1" { + t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1") + } + }) + + t.Run("IP specified - associate unallocated", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + listParams := &cloudstack.ListPublicIpAddressesParams{} + resp := &cloudstack.ListPublicIpAddressesResponse{ + Count: 1, + PublicIpAddresses: []*cloudstack.PublicIpAddress{ + { + Id: "ip-123", + Ipaddress: "203.0.113.1", + Allocated: "", + }, + }, + } + + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + associateResp := &cloudstack.AssociateIpAddressResponse{ + Id: "ip-123", + Ipaddress: "203.0.113.1", + } + + gomock.InOrder( + mockAddress.EXPECT().NewListPublicIpAddressesParams().Return(listParams), + mockAddress.EXPECT().ListPublicIpAddresses(gomock.Any()).Return(resp, nil), + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + ipAddr: "203.0.113.1", + } + + err := lb.getLoadBalancerIP("203.0.113.1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("no IP specified - associate new", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Vpcid: "", + Service: []cloudstack.NetworkServiceInternal{}, + } + + associateParams := &cloudstack.AssociateIpAddressParams{} + associateResp := &cloudstack.AssociateIpAddressResponse{ + Id: "ip-123", + Ipaddress: "203.0.113.1", + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123", gomock.Any()).Return(networkResp, 1, nil), + mockAddress.EXPECT().NewAssociateIpAddressParams().Return(associateParams), + mockAddress.EXPECT().AssociateIpAddress(gomock.Any()).Return(associateResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + networkID: "net-123", + } + + err := lb.getLoadBalancerIP("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lb.ipAddr != "203.0.113.1" { + t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1") + } + }) +} + +func TestCreateLoadBalancerRule(t *testing.T) { + t.Run("create rule with default CIDR", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + createParams := &cloudstack.CreateLoadBalancerRuleParams{} + createResp := &cloudstack.CreateLoadBalancerRuleResponse{ + Id: "rule-123", + Algorithm: "roundrobin", + Cidrlist: defaultAllowedCIDR, + Name: "test-rule-tcp-80", + Networkid: "net-123", + Privateport: "30000", + Publicport: "80", + Publicip: "203.0.113.1", + Publicipid: "ip-123", + Protocol: "tcp", + } + + gomock.InOrder( + mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams), + mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + networkID: "net-123", + ipAddrID: "ip-123", + ipAddr: "203.0.113.1", + } + + port := corev1.ServicePort{ + Port: 80, + NodePort: 30000, + Protocol: corev1.ProtocolTCP, + } + service := &corev1.Service{} + + rule, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rule.Id != "rule-123" { + t.Errorf("rule.Id = %q, want %q", rule.Id, "rule-123") + } + if rule.Name != "test-rule-tcp-80" { + t.Errorf("rule.Name = %q, want %q", rule.Name, "test-rule-tcp-80") + } + }) + + t.Run("create rule with custom CIDR list", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + createParams := &cloudstack.CreateLoadBalancerRuleParams{} + createResp := &cloudstack.CreateLoadBalancerRuleResponse{ + Id: "rule-123", + Algorithm: "roundrobin", + Cidrlist: "10.0.0.0/8,192.168.0.0/16", + Name: "test-rule-tcp-80", + Networkid: "net-123", + Privateport: "30000", + Publicport: "80", + Publicip: "203.0.113.1", + Publicipid: "ip-123", + Protocol: "tcp", + } + + gomock.InOrder( + mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams), + mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + networkID: "net-123", + ipAddrID: "ip-123", + ipAddr: "203.0.113.1", + } + + port := corev1.ServicePort{ + Port: 80, + NodePort: 30000, + Protocol: corev1.ProtocolTCP, + } + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ServiceAnnotationLoadBalancerSourceCidrs: "10.0.0.0/8,192.168.0.0/16", + }, + }, + } + + rule, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rule.Cidrlist != "10.0.0.0/8,192.168.0.0/16" { + t.Errorf("rule.Cidrlist = %q, want %q", rule.Cidrlist, "10.0.0.0/8,192.168.0.0/16") + } + }) + + t.Run("create rule with proxy protocol", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + createParams := &cloudstack.CreateLoadBalancerRuleParams{} + createResp := &cloudstack.CreateLoadBalancerRuleResponse{ + Id: "rule-123", + Algorithm: "roundrobin", + Cidrlist: defaultAllowedCIDR, + Name: "test-rule-tcp-proxy-80", + Networkid: "net-123", + Privateport: "30000", + Publicport: "80", + Publicip: "203.0.113.1", + Publicipid: "ip-123", + Protocol: "tcp-proxy", + } + + gomock.InOrder( + mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-proxy-80", 30000, 80).Return(createParams), + mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + networkID: "net-123", + ipAddrID: "ip-123", + ipAddr: "203.0.113.1", + } + + port := corev1.ServicePort{ + Port: 80, + NodePort: 30000, + Protocol: corev1.ProtocolTCP, + } + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ServiceAnnotationLoadBalancerProxyProtocol: "true", + }, + }, + } + + rule, err := lb.createLoadBalancerRule("test-rule-tcp-proxy-80", port, LoadBalancerProtocolTCPProxy, service) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rule.Protocol != "tcp-proxy" { + t.Errorf("rule.Protocol = %q, want %q", rule.Protocol, "tcp-proxy") + } + }) + + t.Run("error creating rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + createParams := &cloudstack.CreateLoadBalancerRuleParams{} + apiErr := fmt.Errorf("create rule API error") + + gomock.InOrder( + mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams), + mockLB.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + networkID: "net-123", + ipAddrID: "ip-123", + ipAddr: "203.0.113.1", + } + + port := corev1.ServicePort{ + Port: 80, + NodePort: 30000, + Protocol: corev1.ProtocolTCP, + } + service := &corev1.Service{} + + _, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error creating load balancer rule") { + t.Errorf("error message = %q, want to contain 'error creating load balancer rule'", err.Error()) + } + }) + + t.Run("invalid CIDR in annotation", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + createParams := &cloudstack.CreateLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin", "test-rule-tcp-80", 30000, 80).Return(createParams), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + } + + port := corev1.ServicePort{ + Port: 80, + NodePort: 30000, + Protocol: corev1.ProtocolTCP, + } + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ServiceAnnotationLoadBalancerSourceCidrs: "invalid-cidr", + }, + }, + } + + _, err := lb.createLoadBalancerRule("test-rule-tcp-80", port, LoadBalancerProtocolTCP, service) + if err == nil { + t.Fatalf("expected error for invalid CIDR") + } + if !strings.Contains(err.Error(), "invalid CIDR") { + t.Errorf("error message = %q, want to contain 'invalid CIDR'", err.Error()) + } + }) +} + +func TestUpdateLoadBalancerRule(t *testing.T) { + t.Run("update algorithm", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + updateParams := &cloudstack.UpdateLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams), + mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "source", + rules: map[string]*cloudstack.LoadBalancerRule{ + "test-rule-tcp-80": { + Id: "rule-123", + Algorithm: "roundrobin", + Protocol: "tcp", + }, + }, + } + + service := &corev1.Service{} + + err := lb.updateLoadBalancerRule("test-rule-tcp-80", LoadBalancerProtocolTCP, service, semver.Version{Major: 4, Minor: 22, Patch: 0}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("update protocol", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + updateParams := &cloudstack.UpdateLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams), + mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + rules: map[string]*cloudstack.LoadBalancerRule{ + "test-rule-tcp-80": { + Id: "rule-123", + Algorithm: "roundrobin", + Protocol: "tcp", + }, + }, + } + + service := &corev1.Service{} + + err := lb.updateLoadBalancerRule("test-rule-tcp-80", LoadBalancerProtocolTCPProxy, service, semver.Version{Major: 4, Minor: 22, Patch: 0}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("update CIDR list (CS >= 4.22)", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + updateParams := &cloudstack.UpdateLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams), + mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(&cloudstack.UpdateLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + rules: map[string]*cloudstack.LoadBalancerRule{ + "test-rule-tcp-80": { + Id: "rule-123", + Algorithm: "roundrobin", + Protocol: "tcp", + Cidrlist: defaultAllowedCIDR, + }, + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ServiceAnnotationLoadBalancerSourceCidrs: "10.0.0.0/8", + }, + }, + } + + err := lb.updateLoadBalancerRule("test-rule-tcp-80", LoadBalancerProtocolTCP, service, semver.Version{Major: 4, Minor: 22, Patch: 0}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("error updating rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + updateParams := &cloudstack.UpdateLoadBalancerRuleParams{} + apiErr := fmt.Errorf("update rule API error") + + gomock.InOrder( + mockLB.EXPECT().NewUpdateLoadBalancerRuleParams("rule-123").Return(updateParams), + mockLB.EXPECT().UpdateLoadBalancerRule(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + algorithm: "roundrobin", + rules: map[string]*cloudstack.LoadBalancerRule{ + "test-rule-tcp-80": { + Id: "rule-123", + Algorithm: "roundrobin", + Protocol: "tcp", + }, + }, + } + + service := &corev1.Service{} + + err := lb.updateLoadBalancerRule("test-rule-tcp-80", LoadBalancerProtocolTCP, service, semver.Version{Major: 4, Minor: 22, Patch: 0}) + if err == nil { + t.Fatalf("expected error") + } + if err != apiErr { + t.Errorf("error = %v, want %v", err, apiErr) + } + }) +} + +func TestDeleteLoadBalancerRule(t *testing.T) { + t.Run("successful deletion", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + deleteParams := &cloudstack.DeleteLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-123").Return(deleteParams), + mockLB.EXPECT().DeleteLoadBalancerRule(deleteParams).Return(&cloudstack.DeleteLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + rules: map[string]*cloudstack.LoadBalancerRule{ + "test-rule": { + Id: "rule-123", + Name: "test-rule", + }, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.deleteLoadBalancerRule(rule) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, exists := lb.rules["test-rule"]; exists { + t.Errorf("expected rule to be removed from map") + } + }) + + t.Run("error deleting rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + deleteParams := &cloudstack.DeleteLoadBalancerRuleParams{} + apiErr := fmt.Errorf("delete rule API error") + + gomock.InOrder( + mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-123").Return(deleteParams), + mockLB.EXPECT().DeleteLoadBalancerRule(deleteParams).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + rules: map[string]*cloudstack.LoadBalancerRule{ + "test-rule": { + Id: "rule-123", + Name: "test-rule", + }, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.deleteLoadBalancerRule(rule) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error deleting load balancer rule") { + t.Errorf("error message = %q, want to contain 'error deleting load balancer rule'", err.Error()) + } + }) +} + +func TestAssignHostsToRule(t *testing.T) { + t.Run("successful assignment", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + assignParams := &cloudstack.AssignToLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewAssignToLoadBalancerRuleParams("rule-123").Return(assignParams), + mockLB.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.assignHostsToRule(rule, []string{"vm-1", "vm-2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("error assigning hosts", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + assignParams := &cloudstack.AssignToLoadBalancerRuleParams{} + apiErr := fmt.Errorf("assign API error") + + gomock.InOrder( + mockLB.EXPECT().NewAssignToLoadBalancerRuleParams("rule-123").Return(assignParams), + mockLB.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.assignHostsToRule(rule, []string{"vm-1"}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error assigning hosts") { + t.Errorf("error message = %q, want to contain 'error assigning hosts'", err.Error()) + } + }) + + t.Run("empty host list", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + assignParams := &cloudstack.AssignToLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewAssignToLoadBalancerRuleParams("rule-123").Return(assignParams), + mockLB.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.assignHostsToRule(rule, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestRemoveHostsFromRule(t *testing.T) { + t.Run("successful removal", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + removeParams := &cloudstack.RemoveFromLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-123").Return(removeParams), + mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).Return(&cloudstack.RemoveFromLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.removeHostsFromRule(rule, []string{"vm-1", "vm-2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("error removing hosts", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + removeParams := &cloudstack.RemoveFromLoadBalancerRuleParams{} + apiErr := fmt.Errorf("remove API error") + + gomock.InOrder( + mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-123").Return(removeParams), + mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.removeHostsFromRule(rule, []string{"vm-1"}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error removing hosts") { + t.Errorf("error message = %q, want to contain 'error removing hosts'", err.Error()) + } + }) + + t.Run("empty host list", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + removeParams := &cloudstack.RemoveFromLoadBalancerRuleParams{} + + gomock.InOrder( + mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-123").Return(removeParams), + mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).Return(&cloudstack.RemoveFromLoadBalancerRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + rule := &cloudstack.LoadBalancerRule{ + Id: "rule-123", + Name: "test-rule", + } + + err := lb.removeHostsFromRule(rule, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestUpdateFirewallRule(t *testing.T) { + t.Run("create new firewall rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 0, + FirewallRules: []*cloudstack.FirewallRule{}, + } + + createParams := &cloudstack.CreateFirewallRuleParams{} + createResp := &cloudstack.CreateFirewallRuleResponse{ + Id: "fw-123", + } + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams), + mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + ipAddr: "203.0.113.1", + } + + updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) + + t.Run("rule already exists - no change", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 1, + FirewallRules: []*cloudstack.FirewallRule{ + { + Id: "fw-123", + Protocol: "tcp", + Startport: 80, + Endport: 80, + Cidrlist: "10.0.0.0/8", + Ipaddress: "203.0.113.1", + Ipaddressid: "ip-123", + }, + }, + } + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + ipAddr: "203.0.113.1", + } + + updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) + + t.Run("update existing rule - CIDR change", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 1, + FirewallRules: []*cloudstack.FirewallRule{ + { + Id: "fw-123", + Protocol: "tcp", + Startport: 80, + Endport: 80, + Cidrlist: "192.168.0.0/16", + Ipaddress: "203.0.113.1", + Ipaddressid: "ip-123", + }, + }, + } + + deleteParams := &cloudstack.DeleteFirewallRuleParams{} + createParams := &cloudstack.CreateFirewallRuleParams{} + createResp := &cloudstack.CreateFirewallRuleResponse{ + Id: "fw-124", + } + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams), + mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil), + mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams), + mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + ipAddr: "203.0.113.1", + } + + updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) + + t.Run("default CIDR when empty list", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 0, + FirewallRules: []*cloudstack.FirewallRule{}, + } + + createParams := &cloudstack.CreateFirewallRuleParams{} + createResp := &cloudstack.CreateFirewallRuleResponse{ + Id: "fw-123", + } + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams), + mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + ipAddr: "203.0.113.1", + } + + updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) + + t.Run("error listing rules", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + apiErr := fmt.Errorf("list API error") + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + ipAddr: "203.0.113.1", + } + + _, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error fetching firewall rules") { + t.Errorf("error message = %q, want to contain 'error fetching firewall rules'", err.Error()) + } + }) + + t.Run("error creating rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 0, + FirewallRules: []*cloudstack.FirewallRule{}, + } + + createParams := &cloudstack.CreateFirewallRuleParams{} + apiErr := fmt.Errorf("create API error") + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams), + mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + ipAddr: "203.0.113.1", + } + + _, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error creating new firewall rule") { + t.Errorf("error message = %q, want to contain 'error creating new firewall rule'", err.Error()) + } + }) + + t.Run("error deleting rule - continues", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 1, + FirewallRules: []*cloudstack.FirewallRule{ + { + Id: "fw-123", + Protocol: "tcp", + Startport: 80, + Endport: 80, + Cidrlist: "192.168.0.0/16", + Ipaddress: "203.0.113.1", + Ipaddressid: "ip-123", + }, + }, + } + + deleteParams := &cloudstack.DeleteFirewallRuleParams{} + deleteErr := fmt.Errorf("delete API error") + createParams := &cloudstack.CreateFirewallRuleParams{} + createResp := &cloudstack.CreateFirewallRuleResponse{ + Id: "fw-124", + } + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams), + mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(nil, deleteErr), + mockFirewall.EXPECT().NewCreateFirewallRuleParams("ip-123", "tcp").Return(createParams), + mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + ipAddr: "203.0.113.1", + } + + updated, err := lb.updateFirewallRule("ip-123", 80, LoadBalancerProtocolTCP, []string{"10.0.0.0/8"}) + // Should still return true even if delete failed + if err != nil && !strings.Contains(err.Error(), "error creating") { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) +} + +func TestDeleteFirewallRule(t *testing.T) { + t.Run("delete matching rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 1, + FirewallRules: []*cloudstack.FirewallRule{ + { + Id: "fw-123", + Protocol: "tcp", + Startport: 80, + Endport: 80, + Ipaddressid: "ip-123", + }, + }, + } + + deleteParams := &cloudstack.DeleteFirewallRuleParams{} + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams), + mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(&cloudstack.DeleteFirewallRuleResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + } + + deleted, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !deleted { + t.Errorf("deleted = false, want true") + } + }) + + t.Run("no matching rules", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 0, + FirewallRules: []*cloudstack.FirewallRule{}, + } + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + } + + deleted, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deleted { + t.Errorf("deleted = true, want false") + } + }) + + t.Run("error listing rules", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + apiErr := fmt.Errorf("list API error") + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + } + + _, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error fetching firewall rules") { + t.Errorf("error message = %q, want to contain 'error fetching firewall rules'", err.Error()) + } + }) + + t.Run("error deleting rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl) + listParams := &cloudstack.ListFirewallRulesParams{} + listResp := &cloudstack.ListFirewallRulesResponse{ + Count: 1, + FirewallRules: []*cloudstack.FirewallRule{ + { + Id: "fw-123", + Protocol: "tcp", + Startport: 80, + Endport: 80, + Ipaddressid: "ip-123", + }, + }, + } + + deleteParams := &cloudstack.DeleteFirewallRuleParams{} + deleteErr := fmt.Errorf("delete API error") + + gomock.InOrder( + mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams), + mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(listResp, nil), + mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-123").Return(deleteParams), + mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(nil, deleteErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Firewall: mockFirewall, + }, + } + + deleted, err := lb.deleteFirewallRule("ip-123", 80, LoadBalancerProtocolTCP) + // Should return false if deletion failed + if deleted { + t.Errorf("deleted = true, want false") + } + if err != deleteErr { + t.Errorf("error = %v, want %v", err, deleteErr) + } + }) +} + +func TestUpdateNetworkACL(t *testing.T) { + t.Run("create new ACL rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Aclid: "acl-456", + Service: []cloudstack.NetworkServiceInternal{}, + } + + aclListResp := &cloudstack.NetworkACLList{ + Id: "acl-456", + Name: "custom-acl", + } + + listParams := &cloudstack.ListNetworkACLsParams{} + listResp := &cloudstack.ListNetworkACLsResponse{ + Count: 0, + NetworkACLs: []*cloudstack.NetworkACL{}, + } + + createParams := &cloudstack.CreateNetworkACLParams{} + createResp := &cloudstack.CreateNetworkACLResponse{ + Id: "acl-rule-123", + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil), + mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, nil), + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil), + mockNetworkACL.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams), + mockNetworkACL.EXPECT().CreateNetworkACL(gomock.Any()).Return(createResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + NetworkACL: mockNetworkACL, + }, + } + + updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) + + t.Run("rule already exists", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Aclid: "acl-456", + Service: []cloudstack.NetworkServiceInternal{}, + } + + aclListResp := &cloudstack.NetworkACLList{ + Id: "acl-456", + Name: "custom-acl", + } + + listParams := &cloudstack.ListNetworkACLsParams{} + listResp := &cloudstack.ListNetworkACLsResponse{ + Count: 1, + NetworkACLs: []*cloudstack.NetworkACL{ + { + Id: "acl-rule-123", + Protocol: "tcp", + Startport: "80", + Endport: "80", + }, + }, + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil), + mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, nil), + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + NetworkACL: mockNetworkACL, + }, + } + + updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) + + t.Run("default ACL - skip", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Aclid: "acl-456", + Service: []cloudstack.NetworkServiceInternal{}, + } + + aclListResp := &cloudstack.NetworkACLList{ + Id: "acl-456", + Name: "default_allow", + } + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil), + mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + NetworkACL: mockNetworkACL, + }, + } + + updated, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !updated { + t.Errorf("updated = false, want true") + } + }) + + t.Run("error fetching network", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + apiErr := fmt.Errorf("network API error") + + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(nil, 1, apiErr) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + }, + } + + _, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error fetching Network") { + t.Errorf("error message = %q, want to contain 'error fetching Network'", err.Error()) + } + }) + + t.Run("error fetching ACL list", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Aclid: "acl-456", + Service: []cloudstack.NetworkServiceInternal{}, + } + + apiErr := fmt.Errorf("ACL list API error") + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil), + mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(nil, 0, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + NetworkACL: mockNetworkACL, + }, + } + + _, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error fetching Network ACL List") { + t.Errorf("error message = %q, want to contain 'error fetching Network ACL List'", err.Error()) + } + }) + + t.Run("network not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Aclid: "acl-456", + Service: []cloudstack.NetworkServiceInternal{}, + } + + aclListResp := &cloudstack.NetworkACLList{ + Id: "acl-456", + Name: "custom-acl", + } + + listParams := &cloudstack.ListNetworkACLsParams{} + apiErr := fmt.Errorf("list ACL API error") + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil), + mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, nil), + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + NetworkACL: mockNetworkACL, + }, + } + + _, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error fetching Network ACL") { + t.Errorf("error message = %q, want to contain 'error fetching Network ACL'", err.Error()) + } + }) + + t.Run("error creating ACL rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + networkResp := &cloudstack.Network{ + Id: "net-123", + Aclid: "acl-456", + Service: []cloudstack.NetworkServiceInternal{}, + } + + aclListResp := &cloudstack.NetworkACLList{ + Id: "acl-456", + Name: "custom-acl", + } + + listParams := &cloudstack.ListNetworkACLsParams{} + listResp := &cloudstack.ListNetworkACLsResponse{ + Count: 0, + NetworkACLs: []*cloudstack.NetworkACL{}, + } + + createParams := &cloudstack.CreateNetworkACLParams{} + apiErr := fmt.Errorf("create ACL API error") + + gomock.InOrder( + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil), + mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, nil), + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil), + mockNetworkACL.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams), + mockNetworkACL.EXPECT().CreateNetworkACL(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + Network: mockNetwork, + NetworkACL: mockNetworkACL, + }, + } + + _, err := lb.updateNetworkACL(80, LoadBalancerProtocolTCP, "net-123") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error creating Network ACL") { + t.Errorf("error message = %q, want to contain 'error creating Network ACL'", err.Error()) + } + }) +} + +func TestDeleteNetworkACLRule(t *testing.T) { + t.Run("delete matching rule", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + listParams := &cloudstack.ListNetworkACLsParams{} + listResp := &cloudstack.ListNetworkACLsResponse{ + Count: 1, + NetworkACLs: []*cloudstack.NetworkACL{ + { + Id: "acl-rule-123", + Protocol: "tcp", + Startport: "80", + Endport: "80", + }, + }, + } + + deleteParams := &cloudstack.DeleteNetworkACLParams{} + + gomock.InOrder( + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil), + mockNetworkACL.EXPECT().NewDeleteNetworkACLParams("acl-rule-123").Return(deleteParams), + mockNetworkACL.EXPECT().DeleteNetworkACL(deleteParams).Return(&cloudstack.DeleteNetworkACLResponse{}, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + NetworkACL: mockNetworkACL, + }, + } + + deleted, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !deleted { + t.Errorf("deleted = false, want true") + } + }) + + t.Run("no matching rules", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + listParams := &cloudstack.ListNetworkACLsParams{} + listResp := &cloudstack.ListNetworkACLsResponse{ + Count: 0, + NetworkACLs: []*cloudstack.NetworkACL{}, + } + + gomock.InOrder( + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + NetworkACL: mockNetworkACL, + }, + } + + deleted, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !deleted { + t.Errorf("deleted = false, want true") + } + }) + + t.Run("error listing ACLs", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + listParams := &cloudstack.ListNetworkACLsParams{} + apiErr := fmt.Errorf("list ACL API error") + + gomock.InOrder( + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(nil, apiErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + NetworkACL: mockNetworkACL, + }, + } + + _, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error fetching Network ACL rules") { + t.Errorf("error message = %q, want to contain 'error fetching Network ACL rules'", err.Error()) + } + }) + + t.Run("error deleting ACL", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl) + listParams := &cloudstack.ListNetworkACLsParams{} + listResp := &cloudstack.ListNetworkACLsResponse{ + Count: 1, + NetworkACLs: []*cloudstack.NetworkACL{ + { + Id: "acl-rule-123", + Protocol: "tcp", + Startport: "80", + Endport: "80", + }, + }, + } + + deleteParams := &cloudstack.DeleteNetworkACLParams{} + deleteErr := fmt.Errorf("delete ACL API error") + + gomock.InOrder( + mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams), + mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil), + mockNetworkACL.EXPECT().NewDeleteNetworkACLParams("acl-rule-123").Return(deleteParams), + mockNetworkACL.EXPECT().DeleteNetworkACL(deleteParams).Return(nil, deleteErr), + ) + + lb := &loadBalancer{ + CloudStackClient: &cloudstack.CloudStackClient{ + NetworkACL: mockNetworkACL, + }, + } + + deleted, err := lb.deleteNetworkACLRule(80, LoadBalancerProtocolTCP, "net-123") + if deleted { + t.Errorf("deleted = true, want false") + } + if err != deleteErr { + t.Errorf("error = %v, want %v", err, deleteErr) + } + }) +} + +func TestGetLoadBalancer(t *testing.T) { + t.Run("load balancer with existing rules", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + listParams := &cloudstack.ListLoadBalancerRulesParams{} + listResp := &cloudstack.ListLoadBalancerRulesResponse{ + Count: 2, + LoadBalancerRules: []*cloudstack.LoadBalancerRule{ + { + Id: "rule-1", + Name: "test-service-tcp-80", + Publicip: "203.0.113.1", + Publicipid: "ip-123", + Algorithm: "roundrobin", + Protocol: "tcp", + Publicport: "80", + Privateport: "30000", + }, + { + Id: "rule-2", + Name: "test-service-tcp-443", + Publicip: "203.0.113.1", + Publicipid: "ip-123", + Algorithm: "roundrobin", + Protocol: "tcp", + Publicport: "443", + Privateport: "30443", + }, + }, + } + + gomock.InOrder( + mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(listParams), + mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(listResp, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + } + + lb, err := cs.getLoadBalancer(service) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lb.ipAddr != "203.0.113.1" { + t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "203.0.113.1") + } + if lb.ipAddrID != "ip-123" { + t.Errorf("ipAddrID = %q, want %q", lb.ipAddrID, "ip-123") + } + if len(lb.rules) != 2 { + t.Errorf("rules count = %d, want %d", len(lb.rules), 2) + } + }) + + t.Run("load balancer with no rules", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + listParams := &cloudstack.ListLoadBalancerRulesParams{} + listResp := &cloudstack.ListLoadBalancerRulesResponse{ + Count: 0, + LoadBalancerRules: []*cloudstack.LoadBalancerRule{}, + } + + gomock.InOrder( + mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(listParams), + mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(listResp, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + } + + lb, err := cs.getLoadBalancer(service) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(lb.rules) != 0 { + t.Errorf("rules count = %d, want %d", len(lb.rules), 0) + } + if lb.ipAddr != "" { + t.Errorf("ipAddr = %q, want empty", lb.ipAddr) + } + }) + + t.Run("error retrieving rules", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl) + listParams := &cloudstack.ListLoadBalancerRulesParams{} + apiErr := fmt.Errorf("list rules API error") + + gomock.InOrder( + mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(listParams), + mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(nil, apiErr), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + LoadBalancer: mockLB, + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + } + + _, err := cs.getLoadBalancer(service) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "error retrieving load balancer rules") { + t.Errorf("error message = %q, want to contain 'error retrieving load balancer rules'", err.Error()) + } + }) +} + +func TestGetNetworkIDFromIPAddress(t *testing.T) { + t.Run("successful retrieval", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl) + ipResp := &cloudstack.PublicIpAddress{ + Id: "ip-123", + Ipaddress: "203.0.113.1", + Networkid: "net-123", + Associatednetworkid: "net-123", + } + + networkResp := &cloudstack.Network{ + Id: "net-123", + Service: []cloudstack.NetworkServiceInternal{}, + } + + gomock.InOrder( + mockAddress.EXPECT().GetPublicIpAddressByID("ip-123").Return(ipResp, 1, nil), + mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + Address: mockAddress, + Network: mockNetwork, + }, + } + + networkID, err := cs.getNetworkIDFromIPAddress("ip-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if networkID != "net-123" { + t.Errorf("networkID = %q, want %q", networkID, "net-123") + } + }) + + t.Run("IP not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockAddress := cloudstack.NewMockAddressServiceIface(ctrl) + apiErr := fmt.Errorf("IP not found") + + mockAddress.EXPECT().GetPublicIpAddressByID("ip-123").Return(nil, 0, apiErr) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + Address: mockAddress, + }, + } + + _, err := cs.getNetworkIDFromIPAddress("ip-123") + if err == nil { + t.Fatalf("expected error") + } + if err != apiErr { + t.Errorf("error = %v, want %v", err, apiErr) + } + }) +} + +func TestVerifyHosts(t *testing.T) { + t.Run("all hosts in same network", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl) + listParams := &cloudstack.ListVirtualMachinesParams{} + listResp := &cloudstack.ListVirtualMachinesResponse{ + Count: 2, + VirtualMachines: []*cloudstack.VirtualMachine{ + { + Id: "vm-1", + Name: "node-1", + Nic: []cloudstack.Nic{ + {Networkid: "net-123"}, + }, + }, + { + Id: "vm-2", + Name: "node-2", + Nic: []cloudstack.Nic{ + {Networkid: "net-123"}, + }, + }, + }, + } + + gomock.InOrder( + mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams), + mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + VirtualMachine: mockVM, + }, + } + + nodes := []*corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node-1"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "node-2"}}, + } + + hostIDs, networkID, err := cs.verifyHosts(nodes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hostIDs) != 2 { + t.Errorf("hostIDs count = %d, want %d", len(hostIDs), 2) + } + if networkID != "net-123" { + t.Errorf("networkID = %q, want %q", networkID, "net-123") + } + }) + + t.Run("hosts in different networks", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl) + listParams := &cloudstack.ListVirtualMachinesParams{} + listResp := &cloudstack.ListVirtualMachinesResponse{ + Count: 2, + VirtualMachines: []*cloudstack.VirtualMachine{ + { + Id: "vm-1", + Name: "node-1", + Nic: []cloudstack.Nic{ + {Networkid: "net-123"}, + }, + }, + { + Id: "vm-2", + Name: "node-2", + Nic: []cloudstack.Nic{ + {Networkid: "net-456"}, + }, + }, + }, + } + + gomock.InOrder( + mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams), + mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + VirtualMachine: mockVM, + }, + } + + nodes := []*corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node-1"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "node-2"}}, + } + + _, _, err := cs.verifyHosts(nodes) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "different networks") { + t.Errorf("error message = %q, want to contain 'different networks'", err.Error()) + } + }) + + t.Run("no matching hosts", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl) + listParams := &cloudstack.ListVirtualMachinesParams{} + listResp := &cloudstack.ListVirtualMachinesResponse{ + Count: 0, + VirtualMachines: []*cloudstack.VirtualMachine{}, + } + + gomock.InOrder( + mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams), + mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + VirtualMachine: mockVM, + }, + } + + nodes := []*corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node-1"}}, + } + + _, _, err := cs.verifyHosts(nodes) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "none of the hosts matched") { + t.Errorf("error message = %q, want to contain 'none of the hosts matched'", err.Error()) + } + }) + + t.Run("FQDN node names", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl) + listParams := &cloudstack.ListVirtualMachinesParams{} + listResp := &cloudstack.ListVirtualMachinesResponse{ + Count: 1, + VirtualMachines: []*cloudstack.VirtualMachine{ + { + Id: "vm-1", + Name: "node-1", + Nic: []cloudstack.Nic{ + {Networkid: "net-123"}, + }, + }, + }, + } + + gomock.InOrder( + mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams), + mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + VirtualMachine: mockVM, + }, + } + + nodes := []*corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node-1.example.com"}}, + } + + hostIDs, networkID, err := cs.verifyHosts(nodes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hostIDs) != 1 { + t.Errorf("hostIDs count = %d, want %d", len(hostIDs), 1) + } + if networkID != "net-123" { + t.Errorf("networkID = %q, want %q", networkID, "net-123") + } + }) + + t.Run("case-insensitive matching", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl) + listParams := &cloudstack.ListVirtualMachinesParams{} + listResp := &cloudstack.ListVirtualMachinesResponse{ + Count: 1, + VirtualMachines: []*cloudstack.VirtualMachine{ + { + Id: "vm-1", + Name: "NODE-1", + Nic: []cloudstack.Nic{ + {Networkid: "net-123"}, + }, + }, + }, + } + + gomock.InOrder( + mockVM.EXPECT().NewListVirtualMachinesParams().Return(listParams), + mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Return(listResp, nil), + ) + + cs := &CSCloud{ + client: &cloudstack.CloudStackClient{ + VirtualMachine: mockVM, + }, + } + + nodes := []*corev1.Node{ + {ObjectMeta: metav1.ObjectMeta{Name: "node-1"}}, + } + + hostIDs, networkID, err := cs.verifyHosts(nodes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hostIDs) != 1 { + t.Errorf("hostIDs count = %d, want %d", len(hostIDs), 1) + } + if networkID != "net-123" { + t.Errorf("networkID = %q, want %q", networkID, "net-123") + } + }) +} diff --git a/cloudstack_test.go b/cloudstack_test.go index a83b45b8..87ed02fd 100644 --- a/cloudstack_test.go +++ b/cloudstack_test.go @@ -275,3 +275,43 @@ func TestGetManagementServerVersion(t *testing.T) { } }) } + +func TestGetRegionFromZone(t *testing.T) { + tests := []struct { + name string + region string + zone string + want string + }{ + { + name: "region configured in cloud config", + region: "us-east-1", + zone: "zone-1", + want: "us-east-1", + }, + { + name: "region not configured, returns zone", + region: "", + zone: "zone-1", + want: "zone-1", + }, + { + name: "region configured with empty zone", + region: "eu-central-1", + zone: "", + want: "eu-central-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := &CSCloud{ + region: tt.region, + } + got := cs.getRegionFromZone(tt.zone) + if got != tt.want { + t.Errorf("getRegionFromZone(%q) with region=%q = %q, want %q", tt.zone, tt.region, got, tt.want) + } + }) + } +}
