This is an automated email from the ASF dual-hosted git repository.
vishesh92 pushed a commit to branch main
in repository
https://gitbox.apache.org/repos/asf/cloudstack-kubernetes-provider.git
The following commit(s) were added to refs/heads/main by this push:
new 4170ad5c Add pagination to cloudstack api calls (#102)
4170ad5c is described below
commit 4170ad5cc1ead23532acb88b0c5a74c8c56c583d
Author: Vishesh <[email protected]>
AuthorDate: Mon Sep 21 17:50:17 2026 +0530
Add pagination to cloudstack api calls (#102)
---
cloudstack_loadbalancer.go | 146 +++++++++++-----
cloudstack_loadbalancer_test.go | 356 ++++++++++++++++++++++++++++++++++++++++
pagination.go | 118 +++++++++++++
pagination_test.go | 305 ++++++++++++++++++++++++++++++++++
4 files changed, 888 insertions(+), 37 deletions(-)
diff --git a/cloudstack_loadbalancer.go b/cloudstack_loadbalancer.go
index 69d16f51..899d3186 100644
--- a/cloudstack_loadbalancer.go
+++ b/cloudstack_loadbalancer.go
@@ -281,13 +281,21 @@ func (cs *CSCloud) UpdateLoadBalancer(ctx
context.Context, clusterName string, s
for _, lbRule := range lb.rules {
p :=
lb.LoadBalancer.NewListLoadBalancerRuleInstancesParams(lbRule.Id)
- // Retrieve all VMs currently associated to this load balancer
rule.
- l, err := lb.LoadBalancer.ListLoadBalancerRuleInstances(p)
+ // Retrieve all VMs currently associated to this load balancer
rule. There
+ // is one per load balanced node, so this grows with the
cluster.
+ instances, err := listAll(p, func() (int,
[]*cloudstack.VirtualMachine, error) {
+ l, err :=
lb.LoadBalancer.ListLoadBalancerRuleInstances(p)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ return l.Count, l.LoadBalancerRuleInstances, nil
+ })
if err != nil {
return fmt.Errorf("error retrieving associated
instances: %v", err)
}
- assign, remove := symmetricDifference(lb.hostIDs,
l.LoadBalancerRuleInstances)
+ assign, remove := symmetricDifference(lb.hostIDs, instances)
if len(assign) > 0 {
klog.V(4).Infof("Assigning new hosts (%v) to load
balancer rule: %v", assign, lbRule.Name)
@@ -460,11 +468,22 @@ func (cs *CSCloud) getLoadBalancer(service
*corev1.Service) (*loadBalancer, erro
p.SetProjectid(cs.projectID)
}
- l, err := cs.client.LoadBalancer.ListLoadBalancerRules(p)
+ // The keyword is matched as a substring server side, so this can
return more
+ // rules than just this service's and has to be paged through.
+ lbRules, err := listAll(p, func() (int, []*cloudstack.LoadBalancerRule,
error) {
+ l, err := cs.client.LoadBalancer.ListLoadBalancerRules(p)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ return l.Count, l.LoadBalancerRules, nil
+ })
if err != nil {
return nil, fmt.Errorf("error retrieving load balancer rules:
%v", err)
}
+ lbRules = dedupeByID(lbRules, func(rule *cloudstack.LoadBalancerRule)
string { return rule.Id })
+
// Keeping the rule on the address the Service is already published on
stops a
// duplicate sweep from deleting the rule that clients and DNS are
pointing at.
preferredIP := service.Spec.LoadBalancerIP
@@ -472,7 +491,7 @@ func (cs *CSCloud) getLoadBalancer(service *corev1.Service)
(*loadBalancer, erro
preferredIP = service.Status.LoadBalancer.Ingress[0].IP
}
- for _, lbRule := range l.LoadBalancerRules {
+ for _, lbRule := range lbRules {
if existing, seen := lb.rules[lbRule.Name]; seen {
duplicate := lbRule
if lbRule.Publicip == preferredIP && existing.Publicip
!= preferredIP {
@@ -539,24 +558,35 @@ func (cs *CSCloud) verifyHosts(nodes []*corev1.Node)
([]string, string, error) {
p.SetProjectid(cs.projectID)
}
- l, err := cs.client.VirtualMachine.ListVirtualMachines(p)
+ vms, err := listAll(p, func() (int, []*cloudstack.VirtualMachine,
error) {
+ l, err := cs.client.VirtualMachine.ListVirtualMachines(p)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ return l.Count, l.VirtualMachines, nil
+ })
if err != nil {
return nil, "", fmt.Errorf("error retrieving list of hosts:
%v", err)
}
var hostIDs []string
var networkID string
+ seen := map[string]bool{} // used to check whether the changing set of
VMs contains one we had already seen in another page.
// Check if the virtual machine is in the hosts slice, then add the
corresponding ID.
- for _, vm := range l.VirtualMachines {
- if hostNames[strings.ToLower(vm.Name)] {
- if networkID != "" && networkID != vm.Nic[0].Networkid {
- return nil, "", fmt.Errorf("found hosts that
belong to different networks")
- }
+ for _, vm := range vms {
+ if !hostNames[strings.ToLower(vm.Name)] || seen[vm.Id] {
+ continue
+ }
+ seen[vm.Id] = true
- networkID = vm.Nic[0].Networkid
- hostIDs = append(hostIDs, vm.Id)
+ if networkID != "" && networkID != vm.Nic[0].Networkid {
+ return nil, "", fmt.Errorf("found hosts that belong to
different networks")
}
+
+ networkID = vm.Nic[0].Networkid
+ hostIDs = append(hostIDs, vm.Id)
}
if len(hostIDs) == 0 || len(networkID) == 0 {
@@ -903,8 +933,19 @@ func symmetricDifference(hostIDs []string, lbInstances
[]*cloudstack.VirtualMach
new[hostID] = true
}
+ // Paging over the instances of a rule can return the same instance
twice. A
+ // duplicate would otherwise be dropped from new on its first
occurrence and
+ // then added to remove on its second, so the same host would be both
kept
+ // and removed.
+ seen := make(map[string]bool)
+
var remove []string
for _, instance := range lbInstances {
+ if seen[instance.Id] {
+ continue
+ }
+ seen[instance.Id] = true
+
if new[instance.Id] {
delete(new, instance.Id)
continue
@@ -996,6 +1037,37 @@ func rulesMapToString(rules
map[*cloudstack.FirewallRule]bool) string {
return ls.String()
}
+// listFirewallRules retrieves all firewall rules associated with a public IP.
+//
+// Rules are deduplicated by ID: paging over a set that is changing underneath
us
+// can return the same rule on more than one page, and each page decodes into
its
+// own struct, so a repeat arrives as a second pointer to an equal rule.
Callers
+// key their bookkeeping on the pointer, which would treat the two as unrelated
+// rules and delete one of them.
+func (lb *loadBalancer) listFirewallRules(publicIpId string)
([]*cloudstack.FirewallRule, error) {
+ p := lb.Firewall.NewListFirewallRulesParams()
+ p.SetIpaddressid(publicIpId)
+ p.SetListall(true)
+ if lb.projectID != "" {
+ p.SetProjectid(lb.projectID)
+ }
+
+ klog.V(4).Infof("Listing firewall rules for %v", p)
+ rules, err := listAll(p, func() (int, []*cloudstack.FirewallRule,
error) {
+ r, err := lb.Firewall.ListFirewallRules(p)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ return r.Count, r.FirewallRules, nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("error fetching firewall rules for
public IP %v: %v", publicIpId, err)
+ }
+
+ return dedupeByID(rules, func(rule *cloudstack.FirewallRule) string {
return rule.Id }), nil
+}
+
// updateFirewallRule creates a firewall rule for a load balancer rule
//
// If the rule list is empty, all internet (IPv4: 0.0.0.0/0) is opened for the
@@ -1007,23 +1079,16 @@ func (lb *loadBalancer) updateFirewallRule(publicIpId
string, publicPort int, pr
allowedIPs = []string{defaultAllowedCIDR}
}
- p := lb.Firewall.NewListFirewallRulesParams()
- p.SetIpaddressid(publicIpId)
- p.SetListall(true)
- if lb.projectID != "" {
- p.SetProjectid(lb.projectID)
- }
- klog.V(4).Infof("Listing firewall rules for %v", p)
- r, err := lb.Firewall.ListFirewallRules(p)
+ firewallRules, err := lb.listFirewallRules(publicIpId)
if err != nil {
- return false, fmt.Errorf("error fetching firewall rules for
public IP %v: %v", publicIpId, err)
+ return false, err
}
- klog.V(4).Infof("All firewall rules for %v: %v", lb.ipAddr,
rulesToString(r.FirewallRules))
+ klog.V(4).Infof("All firewall rules for %v: %v", lb.ipAddr,
rulesToString(firewallRules))
// find all rules that have a matching proto+port
// a map may or may not be faster, but is a bit easier to understand
filtered := make(map[*cloudstack.FirewallRule]bool)
- for _, rule := range r.FirewallRules {
+ for _, rule := range firewallRules {
if rule.Protocol == protocol.IPProtocol() && rule.Startport ==
publicPort && rule.Endport == publicPort {
filtered[rule] = true
}
@@ -1104,8 +1169,14 @@ func (lb *loadBalancer) updateNetworkACL(publicPort int,
protocol LoadBalancerPr
networkAclParams.SetProjectid(lb.projectID)
}
- networkAclResponse, err :=
lb.NetworkACL.ListNetworkACLs(networkAclParams)
+ networkAcls, err := listAll(networkAclParams, func() (int,
[]*cloudstack.NetworkACL, error) {
+ networkAclResponse, err :=
lb.NetworkACL.ListNetworkACLs(networkAclParams)
+ if err != nil {
+ return 0, nil, err
+ }
+ return networkAclResponse.Count,
networkAclResponse.NetworkACLs, nil
+ })
if err != nil {
return false, fmt.Errorf("error fetching Network ACL with ID:
%v for network with id: %v, due to: %s", network.Aclid, networkId, err)
}
@@ -1113,7 +1184,7 @@ func (lb *loadBalancer) updateNetworkACL(publicPort int,
protocol LoadBalancerPr
// find all network ACL rules that have a matching proto+port
// a map may or may not be faster, but is a bit easier to understand
filtered := make(map[*cloudstack.NetworkACL]bool)
- for _, netAclRule := range networkAclResponse.NetworkACLs {
+ for _, netAclRule := range networkAcls {
if netAclRule.Protocol == protocol.IPProtocol() &&
netAclRule.Startport == strconv.Itoa(publicPort) && netAclRule.Endport ==
strconv.Itoa(publicPort) {
filtered[netAclRule] = true
}
@@ -1145,20 +1216,14 @@ func (lb *loadBalancer) updateNetworkACL(publicPort
int, protocol LoadBalancerPr
//
// returns true when corresponding rules were deleted
func (lb *loadBalancer) deleteFirewallRule(publicIpId string, publicPort int,
protocol LoadBalancerProtocol) (bool, error) {
- p := lb.Firewall.NewListFirewallRulesParams()
- p.SetIpaddressid(publicIpId)
- p.SetListall(true)
- if lb.projectID != "" {
- p.SetProjectid(lb.projectID)
- }
- r, err := lb.Firewall.ListFirewallRules(p)
+ firewallRules, err := lb.listFirewallRules(publicIpId)
if err != nil {
- return false, fmt.Errorf("error fetching firewall rules for
public IP %v: %v", publicIpId, err)
+ return false, err
}
// filter by proto:port
filtered := make([]*cloudstack.FirewallRule, 0, 1)
- for _, rule := range r.FirewallRules {
+ for _, rule := range firewallRules {
if rule.Protocol == protocol.IPProtocol() && rule.Startport ==
publicPort && rule.Endport == publicPort {
filtered = append(filtered, rule)
}
@@ -1188,14 +1253,21 @@ func (lb *loadBalancer) deleteNetworkACLRule(publicPort
int, protocol LoadBalanc
p.SetProjectid(lb.projectID)
}
- r, err := lb.NetworkACL.ListNetworkACLs(p)
+ networkAcls, err := listAll(p, func() (int, []*cloudstack.NetworkACL,
error) {
+ r, err := lb.NetworkACL.ListNetworkACLs(p)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ return r.Count, r.NetworkACLs, nil
+ })
if err != nil {
return false, fmt.Errorf("error fetching Network ACL rules
Network ID %v: %v", networkID, err)
}
// filter by proto:port
filtered := make([]*cloudstack.NetworkACL, 0, 1)
- for _, rule := range r.NetworkACLs {
+ for _, rule := range networkAcls {
if rule.Protocol == protocol.IPProtocol() && rule.Startport ==
strconv.Itoa(publicPort) && rule.Endport == strconv.Itoa(publicPort) {
filtered = append(filtered, rule)
}
diff --git a/cloudstack_loadbalancer_test.go b/cloudstack_loadbalancer_test.go
index 39b229d5..933f7685 100644
--- a/cloudstack_loadbalancer_test.go
+++ b/cloudstack_loadbalancer_test.go
@@ -23,6 +23,7 @@ import (
"context"
"fmt"
"reflect"
+ "slices"
"sort"
"strings"
"testing"
@@ -182,6 +183,30 @@ func TestSymmetricDifference(t *testing.T) {
wantAssign: []string{"host3"},
wantRemove: []string{"host2"},
},
+ {
+ // Paging over a changing result set can return the
same instance on
+ // two pages. A wanted host must not end up in remove
because of it.
+ name: "duplicate instance of a wanted host",
+ hostIDs: []string{"host1", "host2"},
+ lbInstances: []*cloudstack.VirtualMachine{
+ {Id: "host1"},
+ {Id: "host2"},
+ {Id: "host1"},
+ },
+ wantAssign: nil,
+ wantRemove: nil,
+ },
+ {
+ name: "duplicate instance of an unwanted host",
+ hostIDs: []string{"host1"},
+ lbInstances: []*cloudstack.VirtualMachine{
+ {Id: "host1"},
+ {Id: "host2"},
+ {Id: "host2"},
+ },
+ wantAssign: nil,
+ wantRemove: []string{"host2"},
+ },
{
name: "add one host",
hostIDs: []string{"host1", "host2", "host3"},
@@ -2651,6 +2676,74 @@ func TestUpdateFirewallRule(t *testing.T) {
})
}
+func TestListFirewallRulesDeduplicates(t *testing.T) {
+ // Each page decodes into its own structs, so a rule returned on two
pages
+ // arrives as two pointers to an equal rule. updateFirewallRule keys its
+ // bookkeeping on the pointer: it would keep one copy as the CIDR match
and
+ // delete the other by ID, removing the very rule it had just matched
and
+ // creating nothing in its place.
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ rule := func() *cloudstack.FirewallRule {
+ return &cloudstack.FirewallRule{
+ Id: "fw-keep", Protocol: "tcp", Startport: 80, Endport:
80,
+ Cidrlist: defaultAllowedCIDR,
+ }
+ }
+
+ mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
+ mockFirewall.EXPECT().NewListFirewallRulesParams().
+ Return(&cloudstack.ListFirewallRulesParams{})
+
+ // Count of 2 with one rule per page makes listAll page, and the same
rule
+ // comes back both times. It must be built per call: a real second page
is
+ // decoded into its own struct, so the repeat is a distinct pointer.
+ mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Times(2).
+ DoAndReturn(func(p *cloudstack.ListFirewallRulesParams)
(*cloudstack.ListFirewallRulesResponse, error) {
+ return &cloudstack.ListFirewallRulesResponse{
+ Count: 2,
+ FirewallRules:
[]*cloudstack.FirewallRule{rule()},
+ }, nil
+ })
+
+ var deleted []string
+
mockFirewall.EXPECT().NewDeleteFirewallRuleParams(gomock.Any()).AnyTimes().
+ DoAndReturn(func(id string)
*cloudstack.DeleteFirewallRuleParams {
+ deleted = append(deleted, id)
+ return &cloudstack.DeleteFirewallRuleParams{}
+ })
+ mockFirewall.EXPECT().DeleteFirewallRule(gomock.Any()).AnyTimes().
+ Return(&cloudstack.DeleteFirewallRuleResponse{}, nil)
+
+ created := 0
+ mockFirewall.EXPECT().NewCreateFirewallRuleParams(gomock.Any(),
gomock.Any()).AnyTimes().
+ DoAndReturn(func(ip, proto string)
*cloudstack.CreateFirewallRuleParams {
+ created++
+ return &cloudstack.CreateFirewallRuleParams{}
+ })
+ mockFirewall.EXPECT().CreateFirewallRule(gomock.Any()).AnyTimes().
+ Return(&cloudstack.CreateFirewallRuleResponse{}, nil)
+
+ lb := &loadBalancer{
+ CloudStackClient: &cloudstack.CloudStackClient{Firewall:
mockFirewall},
+ ipAddr: "203.0.113.1",
+ }
+
+ // The existing rule already allows exactly what is wanted, so nothing
should
+ // be deleted and nothing created.
+ if _, err := lb.updateFirewallRule("ip-123", 80,
LoadBalancerProtocolTCP, []string{defaultAllowedCIDR}); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(deleted) > 0 {
+ t.Errorf("deleted %v, but that rule already matched the wanted
CIDR", deleted)
+ }
+ if created > 0 {
+ t.Errorf("created %d replacement rules, want 0", created)
+ }
+}
+
func TestDeleteFirewallRule(t *testing.T) {
t.Run("delete matching rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
@@ -3452,6 +3545,49 @@ func TestGetLoadBalancer(t *testing.T) {
})
}
+func TestGetLoadBalancerDeduplicatesPagedRules(t *testing.T) {
+ // A rule returned on two pages arrives as two pointers to an equal
rule.
+ // getLoadBalancer would see the name already in lb.rules, call it a
+ // duplicate, and hand it to the sweep in EnsureLoadBalancer - deleting
the
+ // only rule the Service is published on. The IP preference cannot save
it,
+ // because both copies carry the same address.
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ mockLB.EXPECT().NewListLoadBalancerRulesParams().
+ Return(&cloudstack.ListLoadBalancerRulesParams{})
+ mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Times(2).
+ DoAndReturn(func(p *cloudstack.ListLoadBalancerRulesParams)
(*cloudstack.ListLoadBalancerRulesResponse, error) {
+ return &cloudstack.ListLoadBalancerRulesResponse{
+ Count: 2,
+ LoadBalancerRules:
[]*cloudstack.LoadBalancerRule{
+ {Id: "rule-1", Name: "a-svc-TCP-80",
Publicip: "1.2.3.4", Publicipid: "ip-1"},
+ },
+ }, nil
+ })
+
+ cs := &CSCloud{client: &cloudstack.CloudStackClient{LoadBalancer:
mockLB}}
+ service := &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace:
"default", UID: "abc123"},
+ }
+
+ lb, err := cs.getLoadBalancer(service)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(lb.duplicateRules) != 0 {
+ t.Errorf("rule %v was repeated across pages, not duplicated; it
must not be swept", lb.duplicateRules)
+ }
+ if len(lb.rules) != 1 {
+ t.Errorf("rules = %d, want 1", len(lb.rules))
+ }
+ if lb.ipAddr != "1.2.3.4" {
+ t.Errorf("ipAddr = %q, want %q", lb.ipAddr, "1.2.3.4")
+ }
+}
+
// A failed sweep is reported so the service controller retries, but only after
// this service's own rules and IP are gone, so the retry sees the leftover
// duplicate as an ordinary rule instead of blocking deletion for ever.
@@ -3992,3 +4128,223 @@ func TestVerifyHosts(t *testing.T) {
}
})
}
+
+// pagedRequest is the read side of the paging parameters that every
+// cloudstack-go List*Params exposes.
+type pagedRequest interface {
+ GetPage() (int, bool)
+ GetPagesize() (int, bool)
+}
+
+// pageOf returns the window of items a request asks for, mirroring how
+// CloudStack serves a list: a request carrying no paging parameters comes back
+// truncated at pageSize, and later pages are served by offset. It also asserts
+// the paging contract CloudStack enforces.
+func pageOf[T any](t *testing.T, p pagedRequest, items []T, pageSize int) []T {
+ t.Helper()
+
+ page, paged := p.GetPage()
+ size, sized := p.GetPagesize()
+
+ if paged != sized {
+ t.Errorf("page and pagesize must be sent together, got page set
= %v, pagesize set = %v", paged, sized)
+ }
+
+ switch {
+ case !paged:
+ page, size = 1, pageSize
+ case page < 2:
+ t.Errorf("page = %d, want >= 2 (CloudStack rejects page 0)",
page)
+ case size != pageSize:
+ t.Errorf("pagesize = %d, want %d", size, pageSize)
+ }
+
+ start := (page - 1) * size
+ if start >= len(items) {
+ return nil
+ }
+
+ end := start + size
+ if end > len(items) {
+ end = len(items)
+ }
+
+ return items[start:end]
+}
+
+// nodesNamed builds the node list a cloudprovider call receives.
+func nodesNamed(names ...string) []*corev1.Node {
+ nodes := make([]*corev1.Node, 0, len(names))
+ for _, name := range names {
+ nodes = append(nodes, &corev1.Node{ObjectMeta:
metav1.ObjectMeta{Name: name}})
+ }
+ return nodes
+}
+
+func TestVerifyHostsPagination(t *testing.T) {
+ // CloudStack truncates list responses at default.page.size while still
+ // reporting the full total in count. This is the regression from issue
#99:
+ // with more VMs in the account than fit in one page, the nodes beyond
the
+ // first page were invisible and the load balancer was never created.
+ const pageSize = 500
+
+ // Only the last VM is a cluster node, so it lands on the final page.
+ vms := make([]*cloudstack.VirtualMachine, 750)
+ for i := range vms {
+ vms[i] = &cloudstack.VirtualMachine{
+ Id: fmt.Sprintf("vm-%d", i),
+ Name: fmt.Sprintf("other-%d", i),
+ Nic: []cloudstack.Nic{{Networkid: "net-123"}},
+ }
+ }
+ vms[len(vms)-1].Id = "vm-node-1"
+ vms[len(vms)-1].Name = "node-1"
+
+ t.Run("collects hosts from every page", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
+ // Params are built once and reused across pages.
+ mockVM.EXPECT().NewListVirtualMachinesParams().
+ Return(&cloudstack.ListVirtualMachinesParams{})
+ mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Times(2).
+ DoAndReturn(func(p
*cloudstack.ListVirtualMachinesParams)
(*cloudstack.ListVirtualMachinesResponse, error) {
+ return &cloudstack.ListVirtualMachinesResponse{
+ Count: len(vms),
+ VirtualMachines: pageOf(t, p, vms,
pageSize),
+ }, nil
+ })
+
+ cs := &CSCloud{client:
&cloudstack.CloudStackClient{VirtualMachine: mockVM}}
+
+ hostIDs, networkID, err := cs.verifyHosts(nodesNamed("node-1"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !reflect.DeepEqual(hostIDs, []string{"vm-node-1"}) {
+ t.Errorf("hostIDs = %v, want %v", hostIDs,
[]string{"vm-node-1"})
+ }
+ if networkID != "net-123" {
+ t.Errorf("networkID = %q, want %q", networkID,
"net-123")
+ }
+ })
+
+ t.Run("deduplicates hosts repeated across pages", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ node := &cloudstack.VirtualMachine{
+ Id: "vm-node-1",
+ Name: "node-1",
+ Nic: []cloudstack.Nic{{Networkid: "net-123"}},
+ }
+
+ // A VM is removed between the requests, shifting the offset so
the node
+ // comes back on both pages.
+ pages := [][]*cloudstack.VirtualMachine{{node, node}, {node}}
+
+ mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
+ mockVM.EXPECT().NewListVirtualMachinesParams().
+ Return(&cloudstack.ListVirtualMachinesParams{})
+
mockVM.EXPECT().ListVirtualMachines(gomock.Any()).Times(len(pages)).
+ DoAndReturn(func(p
*cloudstack.ListVirtualMachinesParams)
(*cloudstack.ListVirtualMachinesResponse, error) {
+ page := pages[0]
+ pages = pages[1:]
+ return
&cloudstack.ListVirtualMachinesResponse{Count: 3, VirtualMachines: page}, nil
+ })
+
+ cs := &CSCloud{client:
&cloudstack.CloudStackClient{VirtualMachine: mockVM}}
+
+ hostIDs, _, err := cs.verifyHosts(nodesNamed("node-1"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !reflect.DeepEqual(hostIDs, []string{"vm-node-1"}) {
+ t.Errorf("hostIDs = %v, want %v", hostIDs,
[]string{"vm-node-1"})
+ }
+ })
+}
+
+func TestUpdateLoadBalancerPagination(t *testing.T) {
+ // Instances of a load balancer rule are one per load balanced node, so
on a
+ // large cluster the un-paged response was truncated and the stale
nodes on
+ // later pages were never removed from the rule.
+ const pageSize = 500
+
+ instances := make([]*cloudstack.VirtualMachine, 600)
+ for i := range instances {
+ instances[i] = &cloudstack.VirtualMachine{Id:
fmt.Sprintf("vm-%d", i)}
+ }
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockVM := cloudstack.NewMockVirtualMachineServiceIface(ctrl)
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+
+ // getLoadBalancer: one rule, fits in a single page.
+ mockLB.EXPECT().NewListLoadBalancerRulesParams().
+ Return(&cloudstack.ListLoadBalancerRulesParams{})
+ mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).
+ Return(&cloudstack.ListLoadBalancerRulesResponse{
+ Count: 1,
+ LoadBalancerRules: []*cloudstack.LoadBalancerRule{
+ {Id: "rule-1", Name: "rule-1", Publicip:
"1.2.3.4", Publicipid: "ip-1"},
+ },
+ }, nil)
+
+ // verifyHosts: the cluster is down to a single node.
+ mockVM.EXPECT().NewListVirtualMachinesParams().
+ Return(&cloudstack.ListVirtualMachinesParams{})
+ mockVM.EXPECT().ListVirtualMachines(gomock.Any()).
+ Return(&cloudstack.ListVirtualMachinesResponse{
+ Count: 1,
+ VirtualMachines: []*cloudstack.VirtualMachine{
+ {Id: "vm-0", Name: "node-0", Nic:
[]cloudstack.Nic{{Networkid: "net-123"}}},
+ },
+ }, nil)
+
+ // The rule's members arrive a page at a time.
+ mockLB.EXPECT().NewListLoadBalancerRuleInstancesParams("rule-1").
+ Return(&cloudstack.ListLoadBalancerRuleInstancesParams{})
+ mockLB.EXPECT().ListLoadBalancerRuleInstances(gomock.Any()).Times(2).
+ DoAndReturn(func(p
*cloudstack.ListLoadBalancerRuleInstancesParams)
(*cloudstack.ListLoadBalancerRuleInstancesResponse, error) {
+ return
&cloudstack.ListLoadBalancerRuleInstancesResponse{
+ Count: len(instances),
+ LoadBalancerRuleInstances: pageOf(t, p,
instances, pageSize),
+ }, nil
+ })
+
+ var removed []string
+ mockLB.EXPECT().NewRemoveFromLoadBalancerRuleParams("rule-1").
+ Return(&cloudstack.RemoveFromLoadBalancerRuleParams{})
+ mockLB.EXPECT().RemoveFromLoadBalancerRule(gomock.Any()).
+ DoAndReturn(func(p
*cloudstack.RemoveFromLoadBalancerRuleParams)
(*cloudstack.RemoveFromLoadBalancerRuleResponse, error) {
+ removed, _ = p.GetVirtualmachineids()
+ return
&cloudstack.RemoveFromLoadBalancerRuleResponse{}, nil
+ })
+
+ cs := &CSCloud{
+ client: &cloudstack.CloudStackClient{VirtualMachine: mockVM,
LoadBalancer: mockLB},
+ }
+ service := &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace:
"default", UID: "abc123"},
+ }
+
+ if err := cs.UpdateLoadBalancer(context.TODO(), "cluster", service,
nodesNamed("node-0")); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Every instance except vm-0 must be removed, including those that only
+ // appeared on the second page.
+ if len(removed) != len(instances)-1 {
+ t.Fatalf("removed %d hosts, want %d", len(removed),
len(instances)-1)
+ }
+ if slices.Contains(removed, "vm-0") {
+ t.Errorf("vm-0 is still a node but was removed from the rule")
+ }
+ if !slices.Contains(removed, "vm-599") {
+ t.Errorf("vm-599 is on the second page and should have been
removed, got %v", removed)
+ }
+}
diff --git a/pagination.go b/pagination.go
new file mode 100644
index 00000000..64e19a8c
--- /dev/null
+++ b/pagination.go
@@ -0,0 +1,118 @@
+/*
+ * 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 "fmt"
+
+// maxListPages bounds a single walk. Because the total is re-read on every
page,
+// a result set that grows as fast as it is consumed would otherwise keep the
+// walk going indefinitely. It is a backstop, not a limit expected to be
reached:
+// at CloudStack's default page size it allows half a million records.
+const maxListPages = 1000
+
+// pageableParams is the paging surface that every cloudstack-go List*Params
+// type exposes.
+type pageableParams interface {
+ SetPage(int)
+ SetPagesize(int)
+}
+
+// listAll makes further requests to fetch the remaining items if the count is
higher
+// than the number of items returned. It fails rather than returning a partial
+// list, so callers never reconcile against one.
+//
+// CloudStack offers no cursor, only page and pagesize, so a record removed
from
+// an earlier page shifts the rest down and one on a page boundary can be
missed.
+// Repeats, the other half of that, are dropped by dedupeByID at the call
sites.
+func listAll[T any](p pageableParams, list func() (count int, items []T, err
error)) ([]T, error) {
+ count, items, err := list()
+ if err != nil {
+ return nil, err
+ }
+
+ // Nothing was truncated, or there is nothing to page through.
+ if len(items) >= count || len(items) == 0 {
+ return items, nil
+ }
+
+ // The server just demonstrated how many records it will return at a
time,
+ // which is the one page size it is guaranteed to accept.
+ pageSize := len(items)
+ collected := items
+
+ for page := 2; len(collected) < count; page++ {
+ if page > maxListPages {
+ return nil, fmt.Errorf("gave up paging after %d pages
holding %d of %d records", maxListPages, len(collected), count)
+ }
+
+ p.SetPage(page)
+ p.SetPagesize(pageSize)
+
+ pageCount, items, err := list()
+ if err != nil {
+ return nil, err
+ }
+
+ // Records may be added while we are paging, which pushes the
total up.
+ // Track the highest the server has reported so growth cannot
cut the
+ // walk short; taking the highest rather than the latest also
keeps a
+ // shrinking total from ending the walk before the pages say so.
+ if pageCount > count {
+ count = pageCount
+ }
+
+ // Records may equally have been removed since the first
request, so
+ // trust the pages rather than the count and stop as soon as
one runs
+ // short.
+ if len(items) == 0 {
+ break
+ }
+
+ collected = append(collected, items...)
+
+ if len(items) < pageSize {
+ break
+ }
+ }
+
+ return collected, nil
+}
+
+// dedupeByID drops repeats from a walked list, keeping the first of each ID.
+//
+// A list that changes while it is being walked can return the same record on
+// more than one page, and every page decodes into its own structs, so a repeat
+// arrives as a second pointer to an equal record. Callers that key bookkeeping
+// on the pointer would otherwise treat the two as unrelated records.
+func dedupeByID[T any](items []T, id func(T) string) []T {
+ unique := make([]T, 0, len(items))
+ seen := make(map[string]bool, len(items))
+
+ for _, item := range items {
+ key := id(item)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ unique = append(unique, item)
+ }
+
+ return unique
+}
diff --git a/pagination_test.go b/pagination_test.go
new file mode 100644
index 00000000..aabb6ceb
--- /dev/null
+++ b/pagination_test.go
@@ -0,0 +1,305 @@
+/*
+ * 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 (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+// pagedServer doubles as the params object and the list API, faking a
CloudStack
+// server that truncates a request carrying no page at default.page.size.
+type pagedServer struct {
+ items []int
+ // pageSize is the server's default.page.size.
+ pageSize int
+ // count overrides the reported total when non-zero, to model a server
whose
+ // count disagrees with the records it actually hands out.
+ count int
+
+ // afterEach runs once a response has been built, to model a result set
that
+ // changes while it is being walked.
+ afterEach func(s *pagedServer)
+
+ // Paging parameters as set by listAll. Zero means "not sent".
+ page int
+ reqSize int
+
+ // requests records the (page, pagesize) pair of every call.
+ requests [][2]int
+}
+
+func (s *pagedServer) SetPage(v int) { s.page = v }
+func (s *pagedServer) SetPagesize(v int) { s.reqSize = v }
+
+func (s *pagedServer) list() (int, []int, error) {
+ s.requests = append(s.requests, [2]int{s.page, s.reqSize})
+
+ total := s.count
+ if total == 0 {
+ total = len(s.items)
+ }
+
+ // No page requested: the server applies default.page.size from page
one.
+ page, size := s.page, s.reqSize
+ if page == 0 {
+ page, size = 1, s.pageSize
+ }
+
+ defer func() {
+ if s.afterEach != nil {
+ s.afterEach(s)
+ }
+ }()
+
+ start := (page - 1) * size
+ if start >= len(s.items) {
+ return total, nil, nil
+ }
+
+ end := start + size
+ if end > len(s.items) {
+ end = len(s.items)
+ }
+
+ return total, s.items[start:end], nil
+}
+
+// grow appends n further records, as if they had been created elsewhere while
+// the walk was in progress.
+func (s *pagedServer) grow(n int) {
+ for i := 0; i < n; i++ {
+ s.items = append(s.items, len(s.items))
+ }
+}
+
+func seq(n int) []int {
+ items := make([]int, n)
+ for i := range items {
+ items[i] = i
+ }
+ return items
+}
+
+func TestListAll(t *testing.T) {
+ tests := []struct {
+ name string
+ server pagedServer
+ want []int
+ wantRequests [][2]int
+ }{
+ {
+ name: "fits in one page",
+ server: pagedServer{items: seq(3), pageSize: 500},
+ want: seq(3),
+ wantRequests: [][2]int{{0, 0}},
+ },
+ {
+ name: "empty result",
+ server: pagedServer{items: nil, pageSize: 500},
+ want: nil,
+ wantRequests: [][2]int{{0, 0}},
+ },
+ {
+ name: "exactly one full page",
+ server: pagedServer{items: seq(500), pageSize:
500},
+ want: seq(500),
+ wantRequests: [][2]int{{0, 0}},
+ },
+ {
+ name: "truncated, partial second page",
+ server: pagedServer{items: seq(750), pageSize:
500},
+ want: seq(750),
+ wantRequests: [][2]int{{0, 0}, {2, 500}},
+ },
+ {
+ name: "truncated, exact page multiple",
+ server: pagedServer{items: seq(1000), pageSize:
500},
+ want: seq(1000),
+ wantRequests: [][2]int{{0, 0}, {2, 500}},
+ },
+ {
+ name: "several pages",
+ server: pagedServer{items: seq(12), pageSize: 5},
+ want: seq(12),
+ wantRequests: [][2]int{{0, 0}, {2, 5}, {3, 5}},
+ },
+ {
+ name: "a page size of one still terminates",
+ server: pagedServer{items: seq(3), pageSize: 1},
+ want: seq(3),
+ wantRequests: [][2]int{{0, 0}, {2, 1}, {3, 1}},
+ },
+ {
+ // Records removed between requests: the walk stops on
the short page
+ // rather than spinning until the stale count is
reached.
+ name: "count overstates what the server
returns",
+ server: pagedServer{items: seq(7), pageSize: 5,
count: 100},
+ want: seq(7),
+ wantRequests: [][2]int{{0, 0}, {2, 5}},
+ },
+ {
+ // Every page is full but the count is never reached,
so termination
+ // has to come from the first empty page.
+ name: "count overstates on an exact page
boundary",
+ server: pagedServer{items: seq(10), pageSize: 5,
count: 100},
+ want: seq(10),
+ wantRequests: [][2]int{{0, 0}, {2, 5}, {3, 5}},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := tt.server
+
+ got, err := listAll(&server, server.list)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("items = %v, want %v", got, tt.want)
+ }
+ if !reflect.DeepEqual(server.requests, tt.wantRequests)
{
+ t.Errorf("requests = %v, want %v",
server.requests, tt.wantRequests)
+ }
+ })
+ }
+}
+
+func TestListAllFollowsAGrowingResultSet(t *testing.T) {
+ // The count reported on the first page goes stale as soon as records
are
+ // added, so a walk that trusts only that first total stops early. Here
five
+ // records appear after the first request: a walk pinned to the original
+ // total of 10 would return 10 of the 15 that exist.
+ server := &pagedServer{items: seq(10), pageSize: 5}
+ server.afterEach = func(s *pagedServer) {
+ if len(s.requests) == 1 {
+ s.grow(5)
+ }
+ }
+
+ got, err := listAll(server, server.list)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(got) != 15 {
+ t.Errorf("collected %d records, want 15 - the walk stopped at a
stale total", len(got))
+ }
+ if !reflect.DeepEqual(got, seq(15)) {
+ t.Errorf("items = %v, want %v", got, seq(15))
+ }
+}
+
+func TestListAllFailsOnAGrowingResultSetRunningAway(t *testing.T) {
+ // Re-reading the total on every page means a set that grows exactly as
fast
+ // as it is consumed would never satisfy the loop condition. The page
cap
+ // stops the walk, and it has to fail rather than hand back what it
holds:
+ // callers decide whether rules and hosts exist from these lists, so a
+ // partial one would have them delete or recreate live resources.
+ server := &pagedServer{items: seq(10), pageSize: 5}
+ server.afterEach = func(s *pagedServer) { s.grow(5) }
+
+ got, err := listAll(server, server.list)
+ if err == nil {
+ t.Fatalf("collected %d records with no error, want a failure at
the cap", len(got))
+ }
+ if got != nil {
+ t.Errorf("items = %v, want nil so a partial list cannot be
used", got)
+ }
+ if len(server.requests) != maxListPages {
+ t.Errorf("made %d requests, want the walk capped at %d",
len(server.requests), maxListPages)
+ }
+}
+
+func TestListAllNeverSendsPageZero(t *testing.T) {
+ // CloudStack rejects a page parameter that is merely present, so
page=0 is
+ // an error rather than a way of asking for the first page. The walk
starts
+ // at 2, which makes that unrepresentable.
+ server := &pagedServer{items: seq(150), pageSize: 50}
+
+ if _, err := listAll(server, server.list); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(server.requests) < 2 {
+ t.Fatalf("expected the result to be paged, got %v",
server.requests)
+ }
+ if first := server.requests[0]; first != [2]int{0, 0} {
+ t.Errorf("first request = %v, want no paging parameters at
all", first)
+ }
+ for _, request := range server.requests[1:] {
+ if request[0] < 2 {
+ t.Errorf("request %v used page %d, want >= 2", request,
request[0])
+ }
+ }
+}
+
+func TestListAllPagesWithTheServersOwnPageSize(t *testing.T) {
+ // pagesize may not exceed default.page.size, so the walk has to reuse
the
+ // length the server itself returned rather than a fixed value.
+ server := &pagedServer{items: seq(150), pageSize: 50}
+
+ if _, err := listAll(server, server.list); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ for _, request := range server.requests[1:] {
+ if request[1] != 50 {
+ t.Errorf("request %v used pagesize %d, want 50",
request, request[1])
+ }
+ }
+}
+
+func TestListAllError(t *testing.T) {
+ wantErr := errors.New("boom")
+ server := &pagedServer{}
+
+ t.Run("on the first request", func(t *testing.T) {
+ got, err := listAll(server, func() (int, []int, error) {
+ return 0, nil, wantErr
+ })
+ if !errors.Is(err, wantErr) {
+ t.Errorf("err = %v, want %v", err, wantErr)
+ }
+ if got != nil {
+ t.Errorf("items = %v, want nil", got)
+ }
+ })
+
+ t.Run("on a later page", func(t *testing.T) {
+ calls := 0
+ got, err := listAll(server, func() (int, []int, error) {
+ calls++
+ if calls == 1 {
+ return 750, seq(500), nil
+ }
+ return 0, nil, wantErr
+ })
+ if !errors.Is(err, wantErr) {
+ t.Errorf("err = %v, want %v", err, wantErr)
+ }
+ // A partial result is worse than no result: callers of these
lists treat
+ // a missing record as "does not exist" and create or delete
accordingly.
+ if got != nil {
+ t.Errorf("items = %v, want nil", got)
+ }
+ })
+}