This is an automated email from the ASF dual-hosted git repository.
DaanHoogland pushed a commit to branch main
in repository
https://gitbox.apache.org/repos/asf/cloudstack-kubernetes-provider.git
The following commit(s) were added to refs/heads/main by this push:
new b4335bfa Give each Service its own Network ACL rule (#109)
b4335bfa is described below
commit b4335bfa4fb9ca034ec3f28642bd3572e0592051
Author: Vishesh <[email protected]>
AuthorDate: Thu Oct 22 20:50:13 2026 +0530
Give each Service its own Network ACL rule (#109)
---
README.md | 95 ++++-
cloudstack_loadbalancer.go | 275 +++++++++----
cloudstack_loadbalancer_test.go | 836 ++++++++++++++++++++++++++++++++++++++--
docs/development.md | 8 +-
test/e2e/framework.go | 54 +++
test/e2e/vpc_test.go | 207 +++++++++-
6 files changed, 1362 insertions(+), 113 deletions(-)
diff --git a/README.md b/README.md
index 8314a37f..c3f9ba93 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,10 @@ Stickiness policies also need `listLBStickinessPolicies`,
`createLBStickinessPol
projects cannot call them, so stickiness does not work there yet. Services
without stickiness are
not affected.
+On VPC tiers the controller also calls `updateNetworkACLItem`, to take over
Network ACL rules that
+earlier releases created (see [Per-Service Network ACL
rules](#per-service-network-acl-rules)).
+Without it the controller creates its own rules and leaves the old ones for
you to delete.
+
To create the secret, use the following command:
```bash
kubectl -n kube-system create secret generic cloudstack-secret
--from-file=cloud-config
@@ -406,7 +410,22 @@ chooses the mechanism based on the services the network
offers, so on such netwo
firewall rules instead of Network ACL rules, and
`spec.loadBalancerSourceRanges` is applied to
them.
-Two things to be aware of when the controller manages Network ACL rules:
+When the controller manages Network ACL rules:
+* Each Service gets its own ACL rule for each port, even when another Service
on the tier uses the
+ same port. The rule's reason is `Managed by the CloudStack Kubernetes
Provider for <name>`, where
+ `<name>` is the start of the Service's load balancer rule names: `a`
followed by the Service UID
+ without dashes, cut to 32 characters. The controller only deletes rules with
its Service's reason.
+* If the tier already has an ingress rule for exactly that port (not a port
range) and protocol
+ that the controller did not create, for example one with a restricted CIDR,
a deny rule, or a
+ rule with a reason of its own, the controller adds no rule for that port and
never changes or
+ deletes yours.
+* A rule that looks exactly like the ones earlier releases created (allow,
`0.0.0.0/0`, one port,
+ no reason) is taken over by the first Service that needs its port. Give
rules you create by hand
+ a reason to keep them. Rules in a global ACL list, one that belongs to no
VPC, are never taken
+ over: the controller leaves the port to them and never deletes them.
+* As before, the controller only looks at rules whose protocol is stored in
lower case. Rules stored
+ as `TCP` or `UDP`, such as the ones CloudStack's Kubernetes service adds for
its own ports, are
+ left alone, and the controller adds its own rule next to them.
* The ACL rules the controller creates always allow `0.0.0.0/0`;
`spec.loadBalancerSourceRanges` is
not applied. Use the `cloudstack-load-balancer-source-cidrs` annotation to
restrict sources.
* If the network uses one of the default ACL lists (`default_allow` or
`default_deny`), the
@@ -523,6 +542,80 @@ The controller or CloudStack rejected the stickiness
annotations, and the event
If CloudStack rejects a change, an existing service keeps its current policy.
A new service keeps
its public IP and rules, but stays `<pending>` until the annotation is fixed.
+### A port on a VPC tier stays closed and the controller added no ACL rule for
it
+
+The tier already has an ingress ACL rule for exactly that port and protocol
that the controller did
+not create, so the controller leaves the port to it. Check that rule's action
and CIDRs. If you delete
+it, the controller adds its own rule the next time it syncs the Service, which
happens when the
+Service changes or the controller restarts.
+
+### An ACL rule disappeared when a Service was deleted
+
+The rule allowed exactly `0.0.0.0/0` on one port and had no reason, so the
controller took it over
+as a rule left by an earlier release and deleted it with its Service. Give
rules you create by hand
+a reason.
+
+### A deleted Service stays `Terminating`, log shows `Error deleting Network
ACL rule`
+
+The controller keeps the Service's load balancer rule until its ACL rule is
deleted, because
+Kubernetes only retries the cleanup while a load balancer rule is left. Fix
the cause shown in the
+log, for example the account's permissions, and the cleanup finishes on its
own.
+
+## Upgrading
+
+### Per-Service Network ACL rules
+
+This affects VPC tiers whose ACL rules the controller manages, that is tiers
with a custom ACL list.
+Isolated networks, and VPC networks that use firewall rules, behave as before.
+
+Earlier releases opened a port on a VPC tier with one ACL rule shared by every
Service using that
+port. They deleted it when any of those Services was deleted or stopped using
the port, so the
+other Services lost traffic on that port. Each Service now has its own rule,
marked with its
+reason, and the controller only deletes rules that carry its Service's reason.
+
+What you may notice after upgrading:
+* Existing controller rules get a reason. The controller syncs every Service
when it starts. On that
+ first sync, the first Service using an old rule (allow, `0.0.0.0/0`, one
port, no reason) takes it
+ over by setting its reason.
+* More ACL rules. When several Services use the same port, each gets a rule of
its own, numbered
+ after the existing rules.
+* On that first start, the tier's router applies its ACL list once for each
rule taken over.
+* A rule you created for a Service's port, for example with a restricted CIDR,
a deny action or a
+ reason of its own, is no longer deleted with the Service. As before, the
controller adds no rule
+ of its own on that port.
+* An egress rule for a port no longer stops the controller from opening that
port for ingress. A
+ port that stayed closed because of one opens on the next sync of its Service.
+* Deleting a Service waits until its ACL rule is deleted. While that keeps
failing the Service stays
+ `Terminating`; see
[Troubleshooting](#a-deleted-service-stays-terminating-log-shows-error-deleting-network-acl-rule).
+* Old rules in a global ACL list, one that belongs to no VPC, are never taken
over or deleted.
+
+Before upgrading:
+* Give a reason to every rule you created by hand that allows `0.0.0.0/0` on a
single port of a
+ tier. Without one it looks like an old controller rule, so a Service using
that port takes it
+ over and deletes it together with the Service.
+* Allow the controller's account to call `updateNetworkACLItem`. Without it
the controller cannot
+ take old rules over: it creates a rule of its own next to each one, or keeps
using the old rule if
+ it may not create rules either.
+* Upgrade every controller whose Services share a VPC tier or ACL list, for
example several clusters
+ on one tier. An older controller still treats any rule on its port as its
own and can delete
+ another Service's rule.
+
+After upgrading, check:
+* The controller log for `Cannot adopt Network ACL rule`. The Service now has
a rule of its own, so
+ the old rule named in the message can be deleted once no other Service
relies on it.
+* The controller log for `Cannot create a Network ACL rule for load balancer
... either`. The Service
+ still relies on the old rule named there; keep it, and give the account the
missing permissions.
+* The tier's ACL list for rules with no reason that allow `0.0.0.0/0` on a
port no Service uses.
+ They are left from Services that were deleted, or changed their port or
protocol, before or
+ during the first sync. Old rules in a global ACL list stay too. Delete them
by hand.
+* Services that stay `Terminating`.
+
+If the controller runs with `--concurrent-service-syncs` above 1, two Services
can take over the
+same old rule at once. The one that loses relies on the other's rule until its
next sync.
+
+Downgrading brings the shared behaviour back. An older controller treats any
rule on its port as
+its own, so it can again delete a rule another Service relies on.
+
## Migration Guide
There are several notable differences to the old Kubernetes CloudStack cloud
provider that need to be taken into
diff --git a/cloudstack_loadbalancer.go b/cloudstack_loadbalancer.go
index 753322dc..f94bc01a 100644
--- a/cloudstack_loadbalancer.go
+++ b/cloudstack_loadbalancer.go
@@ -351,6 +351,7 @@ func (cs *CSCloud) EnsureLoadBalancerDeleted(ctx
context.Context, clusterName st
klog.Errorf("Error removing duplicate load balancer rules for
%v/%v: %v", service.Namespace, service.Name, sweepErr)
}
+ var aclErr error
for _, lbRule := range lb.rules {
klog.V(4).Infof("Deleting firewall rules / Network ACLs for
load balancer: %v", lbRule.Name)
protocol := ProtocolFromLoadBalancer(lbRule.Protocol)
@@ -361,9 +362,12 @@ func (cs *CSCloud) EnsureLoadBalancerDeleted(ctx
context.Context, clusterName st
if err != nil {
klog.Errorf("Error parsing port: %v", err)
} else {
- networkId, err :=
cs.getNetworkIDFromIPAddress(lb.ipAddrID)
- if err != nil {
- return err
+ networkId := lbRule.Networkid
+ if networkId == "" {
+ networkId, err =
cs.getNetworkIDFromIPAddress(lbRule.Publicipid)
+ if err != nil {
+ return err
+ }
}
network, count, err :=
lb.Network.GetNetworkByID(networkId, cloudstack.WithProject(lb.projectID))
if err != nil {
@@ -382,7 +386,13 @@ func (cs *CSCloud) EnsureLoadBalancerDeleted(ctx
context.Context, clusterName st
klog.V(4).Infof("Deleting network ACLs
for %v - %v", int(port), protocol)
_, err =
lb.deleteNetworkACLRule(int(port), protocol, networkId)
if err != nil {
- klog.Errorf("Error deleting
Network ACL rule: %v", err)
+ // Kubernetes stops retrying
once no load balancer rule is left, and only
+ // this rule leads back to the
tier and port of its ACL rule, so it is kept.
+ klog.Errorf("Error deleting
Network ACL rule, keeping load balancer rule %v for the retry: %v",
lbRule.Name, err)
+ if aclErr == nil {
+ aclErr = err
+ }
+ continue
}
}
}
@@ -394,6 +404,11 @@ func (cs *CSCloud) EnsureLoadBalancerDeleted(ctx
context.Context, clusterName st
}
}
+ // Releasing the IP would make CloudStack revoke the rules kept for the
retry.
+ if aclErr != nil {
+ return errors.Join(aclErr, sweepErr)
+ }
+
if lb.ipAddr != "" {
// If the IP was allocated by the controller (not specified in
service spec), release it.
if lb.ipAddr != service.Spec.LoadBalancerIP {
@@ -912,9 +927,9 @@ func (lb *loadBalancer) pruneFirewallRule(o obsoleteRule,
claimed map[portProtoc
return err
}
-// pruneNetworkACLRule deletes the network ACL rule admitting traffic to an
obsolete load
-// balancer rule, in the network that rule belongs to. ACL rules belong to a
network rather than
-// an IP, so a claim only covers a rule in the network the service is being
reconciled towards.
+// pruneNetworkACLRule deletes this service's network ACL rule admitting
traffic to an obsolete
+// load balancer rule, in the network that rule belongs to. ACL rules belong
to a network rather
+// than an IP, so a claim only covers a rule in the network the service is
being reconciled towards.
func (lb *loadBalancer) pruneNetworkACLRule(o obsoleteRule, claimed
map[portProtocol]bool, networkID string) error {
lbRule, port := o.rule, int(o.tuple.publicPort)
@@ -1653,8 +1668,9 @@ func (lb *loadBalancer) deleteLoadBalancerRule(lbRule
*cloudstack.LoadBalancerRu
// deleteDuplicateRules removes rules that collided by name with the one being
// managed. A duplicate sits on its own public IP, since CloudStack rejects a
-// second rule on the same IP and port, so its firewall rule and IP go with it;
-// network ACL rules are shared per tier and port with the kept rule and stay.
+// second rule on the same IP and port, so its firewall rule and IP go with it.
+// Network ACL rules are never deleted here: a duplicate has the kept rule's
name,
+// so in the same tier it uses the same ACL rule as the kept one.
func (lb *loadBalancer) deleteDuplicateRules() error {
for _, lbRule := range lb.duplicateRules {
klog.V(4).Infof("Deleting duplicate load balancer rule: %v
(%v)", lbRule.Name, lbRule.Id)
@@ -1952,6 +1968,10 @@ func (lb *loadBalancer) updateFirewallRule(publicIpId
string, publicPort int, pr
return true, err
}
+// updateNetworkACL opens a port on a VPC tier with a Network ACL rule this
load balancer owns.
+// Rules of other load balancers never count, so each Service has its own rule
and deleting one
+// Service leaves the port open for the others (issue #107). A rule from an
earlier release is
+// adopted, and when someone else's rule already covers the port none is added.
func (lb *loadBalancer) updateNetworkACL(publicPort int, protocol
LoadBalancerProtocol, networkId string) (bool, error) {
network, _, err := lb.Network.GetNetworkByID(networkId,
cloudstack.WithProject(lb.projectID))
if err != nil {
@@ -1972,57 +1992,178 @@ func (lb *loadBalancer) updateNetworkACL(publicPort
int, protocol LoadBalancerPr
return true, err
}
- networkAclParams := lb.NetworkACL.NewListNetworkACLsParams()
- networkAclParams.SetAclid(network.Aclid)
- networkAclParams.SetNetworkid(networkId)
- networkAclParams.SetListall(true)
+ ipProtocol := protocol.IPProtocol()
+ aclRules, err := lb.listNetworkACLRules(networkId, ipProtocol,
publicPort)
+ if err != nil {
+ return false, fmt.Errorf("error fetching Network ACL with ID:
%v for network with id: %v, due to: %w", network.Aclid, networkId, err)
+ }
+
+ // Only a root admin may change the rules of a global list, the kind
that belongs to no VPC.
+ adoptable := networkAclList.Vpcid != ""
+ kind, aclRule := lb.decidingNetworkACLRule(aclRules, adoptable)
+ switch kind {
+ case aclRuleOwn:
+ klog.V(4).Infof("Network ACL rule %v already opens %v port %v
for load balancer %v", aclRule.Id, protocol, publicPort, lb.name)
+ return true, nil
+ case aclRuleOperator:
+ klog.Infof("Network ACL rule %v already covers %v port %v on
network %v; not adding one for load balancer %v", aclRule.Id, protocol,
publicPort, networkId, lb.name)
+ return true, nil
+ case aclRuleLegacy:
+ if err := lb.adoptNetworkACLRule(aclRule, network.Aclid,
networkId, ipProtocol, publicPort); err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+
+ if err := lb.createNetworkACLRule(network.Aclid, networkId, ipProtocol,
publicPort); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// networkACLReasonPrefix starts the reason of every Network ACL rule the
controller creates.
+// The rest of the reason names the owning load balancer, and only rules
carrying a load
+// balancer's own reason are ever deleted for it, so this text must not change.
+const networkACLReasonPrefix = "Managed by the CloudStack Kubernetes Provider
for "
+
+// networkACLReason is the reason that marks a Network ACL rule as this load
balancer's.
+func (lb *loadBalancer) networkACLReason() string {
+ return networkACLReasonPrefix + lb.name
+}
+
+// networkACLRuleKind is what an existing Network ACL rule on a needed port
means to a load
+// balancer. The kinds are ordered by precedence.
+type networkACLRuleKind int
+
+const (
+ aclRuleIgnored networkACLRuleKind = iota // being deleted, or another
load balancer's
+ aclRuleOperator // someone else's; the port
is left to them
+ aclRuleLegacy // made by a release before
rules had owners
+ aclRuleOwn // this load balancer's
+)
+
+// classifyNetworkACLRule tells what an ingress rule on the needed port is to
the load balancer
+// whose reason is given. CloudStack shows a rule being revoked as Deleting,
and updating such a
+// rule would bring it back, so it never counts. A rule from an earlier
release has exactly the
+// shape those releases created; any other unmarked rule, such as one with a
restricted CIDR, a
+// deny rule or "0.0.0.0/0,::/0", is someone else's.
+func classifyNetworkACLRule(aclRule *cloudstack.NetworkACL, reason string)
networkACLRuleKind {
+ if aclRule.State == "Deleting" {
+ return aclRuleIgnored
+ }
+ if aclRule.Reason == reason {
+ return aclRuleOwn
+ }
+ if strings.HasPrefix(aclRule.Reason, networkACLReasonPrefix) {
+ return aclRuleIgnored
+ }
+ if aclRule.Reason == "" && aclRule.Action == "Allow" &&
+ compareStringSlice(splitCIDRList(aclRule.Cidrlist),
[]string{defaultAllowedCIDR}) {
+ return aclRuleLegacy
+ }
+ return aclRuleOperator
+}
+
+// decidingNetworkACLRule picks the rule that decides what this load balancer
does about a port:
+// its own rule, else one from an earlier release to adopt, else someone
else's. A rule from an
+// earlier release counts as someone else's when it cannot be adopted.
+func (lb *loadBalancer) decidingNetworkACLRule(aclRules
[]*cloudstack.NetworkACL, adoptable bool) (networkACLRuleKind,
*cloudstack.NetworkACL) {
+ kind, deciding := aclRuleIgnored, (*cloudstack.NetworkACL)(nil)
+ for _, aclRule := range aclRules {
+ k := classifyNetworkACLRule(aclRule, lb.networkACLReason())
+ if k == aclRuleLegacy && !adoptable {
+ k = aclRuleOperator
+ }
+ if k > kind {
+ kind, deciding = k, aclRule
+ }
+ }
+ return kind, deciding
+}
+
+// listNetworkACLRules lists the ingress rules of a tier for exactly one port
and IP protocol.
+// Listing by network covers the ACL list the tier uses now. CloudStack stores
the protocol as it
+// was sent, and the protocol is compared exactly, as earlier releases did, so
rules stored in upper
+// case, such as the "TCP" rules CloudStack's Kubernetes service adds for its
own ports, are left
+// alone.
+func (lb *loadBalancer) listNetworkACLRules(networkID, ipProtocol string,
publicPort int) ([]*cloudstack.NetworkACL, error) {
+ p := lb.NetworkACL.NewListNetworkACLsParams()
+ p.SetNetworkid(networkID)
+ p.SetListall(true)
if lb.projectID != "" {
- networkAclParams.SetProjectid(lb.projectID)
+ p.SetProjectid(lb.projectID)
}
- networkAcls, err := listAll(networkAclParams, func() (int,
[]*cloudstack.NetworkACL, error) {
- networkAclResponse, err :=
lb.NetworkACL.ListNetworkACLs(networkAclParams)
+ aclRules, err := listAll(p, func() (int, []*cloudstack.NetworkACL,
error) {
+ r, err := lb.NetworkACL.ListNetworkACLs(p)
if err != nil {
return 0, nil, err
}
- return networkAclResponse.Count,
networkAclResponse.NetworkACLs, nil
+ return r.Count, r.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)
+ return nil, err
}
- // 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 networkAcls {
- if netAclRule.Protocol == protocol.IPProtocol() &&
netAclRule.Startport == strconv.Itoa(publicPort) && netAclRule.Endport ==
strconv.Itoa(publicPort) {
- filtered[netAclRule] = true
+ port := strconv.Itoa(publicPort)
+ var matching []*cloudstack.NetworkACL
+ for _, aclRule := range dedupeByID(aclRules, func(r
*cloudstack.NetworkACL) string { return r.Id }) {
+ if strings.EqualFold(aclRule.Traffictype, "Ingress") &&
aclRule.Protocol == ipProtocol &&
+ aclRule.Startport == port && aclRule.Endport == port {
+ matching = append(matching, aclRule)
}
}
- if len(filtered) > 0 {
- klog.V(4).Infof("Network ACL rule for port %v and protocol %v
already exists. No need to added a duplicate rule", publicPort, protocol)
- return true, err
+ return matching, nil
+}
+
+// adoptNetworkACLRule marks a rule an earlier release created as this load
balancer's. Only the
+// reason is sent: an update is partial by default, so CloudStack keeps the
other fields. An account
+// that may not update ACL rules creates a rule of its own instead, and one
that may not create
+// them either keeps relying on the old rule, as earlier releases did.
+func (lb *loadBalancer) adoptNetworkACLRule(aclRule *cloudstack.NetworkACL,
aclID, networkID, ipProtocol string, publicPort int) error {
+ p := lb.NetworkACL.NewUpdateNetworkACLItemParams(aclRule.Id)
+ p.SetReason(lb.networkACLReason())
+
+ _, err := lb.NetworkACL.UpdateNetworkACLItem(p)
+ if err == nil {
+ klog.Infof("Adopted Network ACL rule %v for load balancer %v",
aclRule.Id, lb.name)
+ return nil
+ }
+ if !isNotAllowed(err) {
+ return fmt.Errorf("error adopting Network ACL rule %v: %w",
aclRule.Id, err)
+ }
+
+ klog.Warningf("Cannot adopt Network ACL rule %v for load balancer %v,
creating a new one; delete the old rule by hand: %v", aclRule.Id, lb.name, err)
+ err = lb.createNetworkACLRule(aclID, networkID, ipProtocol, publicPort)
+ if err != nil && isNotAllowed(err) {
+ klog.Warningf("Cannot create a Network ACL rule for load
balancer %v either; port %v stays open through rule %v: %v", lb.name,
publicPort, aclRule.Id, err)
+ return nil
}
- // create ACL rule
- // ACL rules only know tcp/udp/icmp, so tcp-proxy maps to tcp. This
also matches the
- // filter above, which would otherwise never find the rule again.
- acl := lb.NetworkACL.NewCreateNetworkACLParams(protocol.IPProtocol())
- acl.SetAclid(network.Aclid)
+ return err
+}
+
+// createNetworkACLRule opens a port on a tier with a rule marked as this load
balancer's. ACL
+// rules only know tcp, udp and icmp, so the rule takes the IP protocol and a
tcp-proxy port
+// opens tcp.
+func (lb *loadBalancer) createNetworkACLRule(aclID, networkID, ipProtocol
string, publicPort int) error {
+ acl := lb.NetworkACL.NewCreateNetworkACLParams(ipProtocol)
+ acl.SetAclid(aclID)
acl.SetAction("Allow")
- acl.SetCidrlist([]string{"0.0.0.0/0"})
+ acl.SetCidrlist([]string{defaultAllowedCIDR})
acl.SetStartport(publicPort)
acl.SetEndport(publicPort)
- acl.SetNetworkid(networkId)
+ acl.SetNetworkid(networkID)
acl.SetTraffictype("Ingress")
+ acl.SetReason(lb.networkACLReason())
- _, err = lb.NetworkACL.CreateNetworkACL(acl)
- if err != nil {
- return false, fmt.Errorf("error creating Network ACL for port:
%v, due to: %s", publicPort, err)
+ if _, err := lb.NetworkACL.CreateNetworkACL(acl); err != nil {
+ return fmt.Errorf("error creating Network ACL for port: %v, due
to: %w", publicPort, err)
}
- return true, err
+
+ return nil
}
// deleteFirewallRule deletes the firewall rule associated with the
ip:port:protocol combo
@@ -2057,51 +2198,39 @@ func (lb *loadBalancer) deleteFirewallRule(publicIpId
string, publicPort int, pr
return deleted, err
}
-// Delete Network ACLs deletes the Network ACL rule associated with the
ip:port:protocol combo
+// deleteNetworkACLRule deletes this load balancer's Network ACL rules for a
port on a tier and
+// leaves every other rule alone, so another Service using the same port keeps
it open. A rule
+// CloudStack still shows as Deleting is deleted again, which retries applying
the list; a failure
+// there is only logged, as CloudStack removes the rule on its next successful
apply. Every rule
+// is tried and the first error is returned.
func (lb *loadBalancer) deleteNetworkACLRule(publicPort int, protocol
LoadBalancerProtocol, networkID string) (bool, error) {
- p := lb.NetworkACL.NewListNetworkACLsParams()
- p.SetListall(true)
- p.SetNetworkid(networkID)
- if lb.projectID != "" {
- p.SetProjectid(lb.projectID)
+ aclRules, err := lb.listNetworkACLRules(networkID,
protocol.IPProtocol(), publicPort)
+ if err != nil {
+ return false, fmt.Errorf("error fetching Network ACL rules
Network ID %v: %w", networkID, err)
}
- networkAcls, err := listAll(p, func() (int, []*cloudstack.NetworkACL,
error) {
- r, err := lb.NetworkACL.ListNetworkACLs(p)
- if err != nil {
- return 0, nil, err
+ owned := 0
+ var firstErr error
+ for _, aclRule := range aclRules {
+ if aclRule.Reason != lb.networkACLReason() {
+ continue
}
+ owned++
- 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 networkAcls {
- if rule.Protocol == protocol.IPProtocol() && rule.Startport ==
strconv.Itoa(publicPort) && rule.Endport == strconv.Itoa(publicPort) {
- filtered = append(filtered, rule)
+ p := lb.NetworkACL.NewDeleteNetworkACLParams(aclRule.Id)
+ if _, err := lb.NetworkACL.DeleteNetworkACL(p); err != nil {
+ klog.Errorf("Error deleting Network ACL rule %v: %v",
aclRule.Id, err)
+ if aclRule.State != "Deleting" && firstErr == nil {
+ firstErr = err
+ }
}
}
- // delete first filtered rules
- if len(filtered) == 0 {
- klog.V(4).Infof("No ACL rules found matching protocol: %v and
port: %v", protocol, publicPort)
- return true, nil
- }
- deleted := false
- ruleToBeDeleted := filtered[0]
- deleteAclParams :=
lb.NetworkACL.NewDeleteNetworkACLParams(ruleToBeDeleted.Id)
- _, err = lb.NetworkACL.DeleteNetworkACL(deleteAclParams)
- if err != nil {
- klog.Errorf("Error deleting old Network ACL rule %v: %v",
ruleToBeDeleted.Id, err)
- } else {
- deleted = true
+ if owned == 0 {
+ klog.V(4).Infof("No Network ACL rules of load balancer %v found
for protocol %v and port %v", lb.name, protocol, publicPort)
}
- return deleted, err
+ return firstErr == nil, firstErr
}
// getStringFromServiceAnnotation searches a given v1.Service for a specific
annotationKey and either returns the annotation's value or a specified
defaultSetting
diff --git a/cloudstack_loadbalancer_test.go b/cloudstack_loadbalancer_test.go
index 24e8a14a..91ac15c4 100644
--- a/cloudstack_loadbalancer_test.go
+++ b/cloudstack_loadbalancer_test.go
@@ -3090,6 +3090,33 @@ func TestDeleteFirewallRule(t *testing.T) {
})
}
+// testACLReason marks a Network ACL rule as owned by the test load balancer
"atestuid".
+const testACLReason = networkACLReasonPrefix + "atestuid"
+
+// legacyACLRule is an ingress tcp rule for port 80 shaped as earlier releases
created it. edit
+// turns it into the kind of rule a test needs.
+func legacyACLRule(id string, edit func(*cloudstack.NetworkACL))
*cloudstack.NetworkACL {
+ aclRule := &cloudstack.NetworkACL{
+ Id: id,
+ Protocol: "tcp",
+ Startport: "80",
+ Endport: "80",
+ Traffictype: "Ingress",
+ Action: "Allow",
+ Cidrlist: defaultAllowedCIDR,
+ State: "Active",
+ }
+ if edit != nil {
+ edit(aclRule)
+ }
+ return aclRule
+}
+
+// ownACLRule is the tcp/80 rule of the test load balancer "atestuid".
+func ownACLRule(id string) *cloudstack.NetworkACL {
+ return legacyACLRule(id, func(r *cloudstack.NetworkACL) { r.Reason =
testACLReason })
+}
+
func TestUpdateNetworkACL(t *testing.T) {
t.Run("create new ACL rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
@@ -3129,6 +3156,7 @@ func TestUpdateNetworkACL(t *testing.T) {
)
lb := &loadBalancer{
+ name: "atestuid",
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
@@ -3142,6 +3170,15 @@ func TestUpdateNetworkACL(t *testing.T) {
if !updated {
t.Errorf("updated = false, want true")
}
+ if reason, _ := createParams.GetReason(); reason !=
testACLReason {
+ t.Errorf("reason = %q, want %q", reason, testACLReason)
+ }
+ if cidrs, _ := createParams.GetCidrlist();
!compareStringSlice(cidrs, []string{defaultAllowedCIDR}) {
+ t.Errorf("cidrlist = %v, want [%v]", cidrs,
defaultAllowedCIDR)
+ }
+ if trafficType, _ := createParams.GetTraffictype(); trafficType
!= "Ingress" {
+ t.Errorf("traffictype = %q, want Ingress", trafficType)
+ }
})
t.Run("tcp-proxy creates ACL rule with tcp protocol", func(t
*testing.T) {
@@ -3198,7 +3235,7 @@ func TestUpdateNetworkACL(t *testing.T) {
}
})
- t.Run("tcp-proxy matches existing tcp ACL rule", func(t *testing.T) {
+ t.Run("tcp-proxy matches this service's tcp ACL rule", func(t
*testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
@@ -3219,12 +3256,7 @@ func TestUpdateNetworkACL(t *testing.T) {
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
- {
- Id: "acl-rule-123",
- Protocol: "tcp",
- Startport: "80",
- Endport: "80",
- },
+ ownACLRule("acl-rule-123"),
},
}
@@ -3237,6 +3269,7 @@ func TestUpdateNetworkACL(t *testing.T) {
)
lb := &loadBalancer{
+ name: "atestuid",
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
@@ -3252,7 +3285,7 @@ func TestUpdateNetworkACL(t *testing.T) {
}
})
- t.Run("rule already exists", func(t *testing.T) {
+ t.Run("this service's rule already exists", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
@@ -3273,12 +3306,7 @@ func TestUpdateNetworkACL(t *testing.T) {
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
- {
- Id: "acl-rule-123",
- Protocol: "tcp",
- Startport: "80",
- Endport: "80",
- },
+ ownACLRule("acl-rule-123"),
},
}
@@ -3290,6 +3318,7 @@ func TestUpdateNetworkACL(t *testing.T) {
)
lb := &loadBalancer{
+ name: "atestuid",
CloudStackClient: &cloudstack.CloudStackClient{
Network: mockNetwork,
NetworkACL: mockNetworkACL,
@@ -3498,7 +3527,7 @@ func TestUpdateNetworkACL(t *testing.T) {
}
func TestDeleteNetworkACLRule(t *testing.T) {
- t.Run("delete matching rule", func(t *testing.T) {
+ t.Run("delete this service's rule", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
@@ -3507,12 +3536,7 @@ func TestDeleteNetworkACLRule(t *testing.T) {
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
- {
- Id: "acl-rule-123",
- Protocol: "tcp",
- Startport: "80",
- Endport: "80",
- },
+ ownACLRule("acl-rule-123"),
},
}
@@ -3526,6 +3550,7 @@ func TestDeleteNetworkACLRule(t *testing.T) {
)
lb := &loadBalancer{
+ name: "atestuid",
CloudStackClient: &cloudstack.CloudStackClient{
NetworkACL: mockNetworkACL,
},
@@ -3608,12 +3633,7 @@ func TestDeleteNetworkACLRule(t *testing.T) {
listResp := &cloudstack.ListNetworkACLsResponse{
Count: 1,
NetworkACLs: []*cloudstack.NetworkACL{
- {
- Id: "acl-rule-123",
- Protocol: "tcp",
- Startport: "80",
- Endport: "80",
- },
+ ownACLRule("acl-rule-123"),
},
}
@@ -3628,6 +3648,7 @@ func TestDeleteNetworkACLRule(t *testing.T) {
)
lb := &loadBalancer{
+ name: "atestuid",
CloudStackClient: &cloudstack.CloudStackClient{
NetworkACL: mockNetworkACL,
},
@@ -3643,6 +3664,426 @@ func TestDeleteNetworkACLRule(t *testing.T) {
})
}
+func TestClassifyNetworkACLRule(t *testing.T) {
+ tests := []struct {
+ name string
+ edit func(*cloudstack.NetworkACL)
+ want networkACLRuleKind
+ }{
+ {"this service's rule", func(r *cloudstack.NetworkACL) {
r.Reason = testACLReason }, aclRuleOwn},
+ {"this service's rule being deleted", func(r
*cloudstack.NetworkACL) { r.Reason = testACLReason; r.State = "Deleting" },
aclRuleIgnored},
+ {"this service's rule with a CIDR edited by hand", func(r
*cloudstack.NetworkACL) { r.Reason = testACLReason; r.Cidrlist = "10.0.0.0/8"
}, aclRuleOwn},
+ {"another service's rule", func(r *cloudstack.NetworkACL) {
r.Reason = networkACLReasonPrefix + "aotheruid" }, aclRuleIgnored},
+ {"a rule from an earlier release", nil, aclRuleLegacy},
+ {"a rule from an earlier release being deleted", func(r
*cloudstack.NetworkACL) { r.State = "Deleting" }, aclRuleIgnored},
+ {"a restricted CIDR", func(r *cloudstack.NetworkACL) {
r.Cidrlist = "10.0.0.0/8" }, aclRuleOperator},
+ {"an IPv6 range as well", func(r *cloudstack.NetworkACL) {
r.Cidrlist = "0.0.0.0/0,::/0" }, aclRuleOperator},
+ {"a deny rule", func(r *cloudstack.NetworkACL) { r.Action =
"Deny" }, aclRuleOperator},
+ {"a reason of its own", func(r *cloudstack.NetworkACL) {
r.Reason = "allow https" }, aclRuleOperator},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := classifyNetworkACLRule(legacyACLRule("acl-1",
tt.edit), testACLReason); got != tt.want {
+ t.Errorf("classifyNetworkACLRule() = %v, want
%v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestListNetworkACLRules(t *testing.T) {
+ t.Run("keeps ingress rules for exactly the port and protocol", func(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
+ listParams := &cloudstack.ListNetworkACLsParams{}
+
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams)
+
mockNetworkACL.EXPECT().ListNetworkACLs(listParams).Return(&cloudstack.ListNetworkACLsResponse{
+ Count: 8,
+ NetworkACLs: []*cloudstack.NetworkACL{
+ legacyACLRule("tcp-80", nil),
+ legacyACLRule("upper-case-tcp-80", func(r
*cloudstack.NetworkACL) { r.Protocol = "TCP" }),
+ legacyACLRule("deleting-tcp-80", func(r
*cloudstack.NetworkACL) { r.State = "Deleting" }),
+ legacyACLRule("egress-tcp-80", func(r
*cloudstack.NetworkACL) { r.Traffictype = "Egress" }),
+ legacyACLRule("udp-80", func(r
*cloudstack.NetworkACL) { r.Protocol = "udp" }),
+ legacyACLRule("tcp-81", func(r
*cloudstack.NetworkACL) { r.Startport, r.Endport = "81", "81" }),
+ legacyACLRule("tcp-80-81", func(r
*cloudstack.NetworkACL) { r.Endport = "81" }),
+ legacyACLRule("all", func(r
*cloudstack.NetworkACL) { r.Protocol, r.Startport, r.Endport = "all", "", "" }),
+ },
+ }, nil)
+
+ lb := &loadBalancer{
+ CloudStackClient:
&cloudstack.CloudStackClient{NetworkACL: mockNetworkACL},
+ projectID: "proj-1",
+ }
+
+ aclRules, err := lb.listNetworkACLRules("net-1", "tcp", 80)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var ids []string
+ for _, aclRule := range aclRules {
+ ids = append(ids, aclRule.Id)
+ }
+ if want := []string{"tcp-80", "deleting-tcp-80"};
!compareStringSlice(ids, want) {
+ t.Errorf("rules = %v, want %v", ids, want)
+ }
+ if networkID, _ := listParams.GetNetworkid(); networkID !=
"net-1" {
+ t.Errorf("networkid = %q, want net-1", networkID)
+ }
+ if listAll, _ := listParams.GetListall(); !listAll {
+ t.Errorf("listall not set")
+ }
+ if projectID, _ := listParams.GetProjectid(); projectID !=
"proj-1" {
+ t.Errorf("projectid = %q, want proj-1", projectID)
+ }
+ })
+
+ t.Run("a rule repeated across pages is returned once", func(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ var all []*cloudstack.NetworkACL
+ for i := 0; i < 501; i++ {
+ all = append(all, legacyACLRule(fmt.Sprintf("acl-%d",
i), func(r *cloudstack.NetworkACL) { r.Startport, r.Endport = "81", "81" }))
+ }
+ all[499] = ownACLRule("acl-own")
+ all[500] = ownACLRule("acl-own")
+
+ mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
+ listParams := &cloudstack.ListNetworkACLsParams{}
+
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams)
+
mockNetworkACL.EXPECT().ListNetworkACLs(listParams).DoAndReturn(func(p
*cloudstack.ListNetworkACLsParams) (*cloudstack.ListNetworkACLsResponse, error)
{
+ return &cloudstack.ListNetworkACLsResponse{Count:
len(all), NetworkACLs: pageOf(t, p, all, 500)}, nil
+ }).Times(2)
+
+ lb := &loadBalancer{CloudStackClient:
&cloudstack.CloudStackClient{NetworkACL: mockNetworkACL}}
+
+ aclRules, err := lb.listNetworkACLRules("net-1", "tcp", 80)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(aclRules) != 1 {
+ t.Errorf("rules = %d, want acl-own once", len(aclRules))
+ }
+ })
+
+ t.Run("pages through every rule of the tier once", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ var all []*cloudstack.NetworkACL
+ for i := 0; i < 600; i++ {
+ all = append(all, legacyACLRule(fmt.Sprintf("acl-%d",
i), func(r *cloudstack.NetworkACL) { r.Startport, r.Endport = "81", "81" }))
+ }
+ all[550] = ownACLRule("acl-own")
+
+ mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
+ listParams := &cloudstack.ListNetworkACLsParams{}
+
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams)
+
mockNetworkACL.EXPECT().ListNetworkACLs(listParams).DoAndReturn(func(p
*cloudstack.ListNetworkACLsParams) (*cloudstack.ListNetworkACLsResponse, error)
{
+ return &cloudstack.ListNetworkACLsResponse{Count:
len(all), NetworkACLs: pageOf(t, p, all, 500)}, nil
+ }).Times(2)
+
+ lb := &loadBalancer{CloudStackClient:
&cloudstack.CloudStackClient{NetworkACL: mockNetworkACL}}
+
+ aclRules, err := lb.listNetworkACLRules("net-1", "tcp", 80)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(aclRules) != 1 || aclRules[0].Id != "acl-own" {
+ t.Errorf("rules = %v, want only acl-own from the second
page", aclRules)
+ }
+ })
+}
+
+// sentUpdateFields names the fields of an updateNetworkACLItem call that were
set, apart from
+// the id, so a test can assert that an adoption sends nothing but the reason.
+func sentUpdateFields(p *cloudstack.UpdateNetworkACLItemParams) []string {
+ var sent []string
+ set := func(name string, ok bool) {
+ if ok {
+ sent = append(sent, name)
+ }
+ }
+ _, ok := p.GetAction()
+ set("action", ok)
+ _, ok = p.GetCidrlist()
+ set("cidrlist", ok)
+ _, ok = p.GetCustomid()
+ set("customid", ok)
+ _, ok = p.GetEndport()
+ set("endport", ok)
+ _, ok = p.GetFordisplay()
+ set("fordisplay", ok)
+ _, ok = p.GetIcmpcode()
+ set("icmpcode", ok)
+ _, ok = p.GetIcmptype()
+ set("icmptype", ok)
+ _, ok = p.GetNumber()
+ set("number", ok)
+ _, ok = p.GetPartialupgrade()
+ set("partialupgrade", ok)
+ _, ok = p.GetProtocol()
+ set("protocol", ok)
+ _, ok = p.GetReason()
+ set("reason", ok)
+ _, ok = p.GetStartport()
+ set("startport", ok)
+ _, ok = p.GetTraffictype()
+ set("traffictype", ok)
+ return sent
+}
+
+// Regression test for issue #107: a Network ACL rule is owned by one Service,
so a rule of
+// another Service, or one an operator made, never stands in for this
Service's own rule.
+func TestUpdateNetworkACLOwnership(t *testing.T) {
+ notAllowed := fmt.Errorf("CloudStack API error 432
(CSExceptionErrorCode: 9999): The API [updateNetworkACLItem] does not exist or
is not available for the account")
+ otherService := func(r *cloudstack.NetworkACL) { r.Reason =
networkACLReasonPrefix + "aotheruid" }
+
+ tests := []struct {
+ name string
+ rules []*cloudstack.NetworkACL
+ protocol LoadBalancerProtocol
+ globalList bool
+ adopt string
+ adoptErr error
+ wantCreate bool
+ createErr error
+ wantErr string
+ }{
+ {name: "creates its own rule next to another service's rule",
rules: []*cloudstack.NetworkACL{legacyACLRule("acl-other", otherService)},
wantCreate: true},
+ {name: "an egress rule does not open the port", rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-egress", func(r
*cloudstack.NetworkACL) { r.Traffictype = "Egress" })}, wantCreate: true},
+ {name: "its own rule being deleted does not open the port",
rules: []*cloudstack.NetworkACL{legacyACLRule("acl-own", func(r
*cloudstack.NetworkACL) { r.Reason = testACLReason; r.State = "Deleting" })},
wantCreate: true},
+ {name: "adopts a rule from an earlier release", rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-legacy", nil)}, adopt:
"acl-legacy"},
+ {name: "adopts only one of several rules from an earlier
release", rules: []*cloudstack.NetworkACL{legacyACLRule("acl-legacy-1", nil),
legacyACLRule("acl-legacy-2", nil)}, adopt: "acl-legacy-1"},
+ {name: "keeps its own rule rather than adopting another",
rules: []*cloudstack.NetworkACL{legacyACLRule("acl-legacy", nil),
ownACLRule("acl-own")}},
+ {name: "adopts a rule from an earlier release before deferring
to an operator", rules: []*cloudstack.NetworkACL{legacyACLRule("acl-operator",
func(r *cloudstack.NetworkACL) { r.Cidrlist = "10.0.0.0/8" }),
legacyACLRule("acl-legacy", nil)}, adopt: "acl-legacy"},
+ {name: "creates its own rule when adopting is not allowed",
rules: []*cloudstack.NetworkACL{legacyACLRule("acl-legacy", nil)}, adopt:
"acl-legacy", adoptErr: notAllowed, wantCreate: true},
+ {name: "keeps relying on the old rule when adopting and
creating are both refused", rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-legacy", nil)}, adopt:
"acl-legacy", adoptErr: notAllowed, wantCreate: true, createErr:
fmt.Errorf("CloudStack API error 432 (CSExceptionErrorCode: 9999): The API
[createNetworkACL] does not exist or is not available for the account")},
+ {name: "adopts a udp rule from an earlier release", protocol:
LoadBalancerProtocolUDP, rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-legacy-udp", func(r
*cloudstack.NetworkACL) { r.Protocol = "udp" })}, adopt: "acl-legacy-udp"},
+ {name: "creates a udp rule next to a tcp rule on the same
port", protocol: LoadBalancerProtocolUDP, rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-legacy-tcp", nil)}, wantCreate:
true},
+ {name: "fails when adopting fails otherwise", rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-legacy", nil)}, adopt:
"acl-legacy", adoptErr: fmt.Errorf("router unreachable"), wantErr: "error
adopting Network ACL rule acl-legacy"},
+ {name: "leaves the port to an operator rule with a restricted
CIDR", rules: []*cloudstack.NetworkACL{legacyACLRule("acl-operator", func(r
*cloudstack.NetworkACL) { r.Cidrlist = "10.0.0.0/8" })}},
+ {name: "adds its own rule next to an upper case TCP rule, as
earlier releases did", rules: []*cloudstack.NetworkACL{legacyACLRule("acl-cks",
func(r *cloudstack.NetworkACL) { r.Protocol = "TCP"; r.Cidrlist =
"0.0.0.0/0,::/0" })}, wantCreate: true},
+ {name: "leaves the port to an operator deny rule", rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-deny", func(r
*cloudstack.NetworkACL) { r.Action = "Deny" })}},
+ {name: "never adopts in a global ACL list", rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-legacy", nil)}, globalList: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ listVpcID := "vpc-1"
+ if tt.globalList {
+ listVpcID = ""
+ }
+
+ mockNetwork :=
cloudstack.NewMockNetworkServiceIface(ctrl)
+ mockNetworkACL :=
cloudstack.NewMockNetworkACLServiceIface(ctrl)
+ mockNetwork.EXPECT().GetNetworkByID("net-1",
gomock.Any()).Return(&cloudstack.Network{Id: "net-1", Aclid: "acl-1"}, 1, nil)
+ mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-1",
gomock.Any()).Return(&cloudstack.NetworkACLList{Id: "acl-1", Name: "tier-acl",
Vpcid: listVpcID}, 1, nil)
+
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(&cloudstack.ListNetworkACLsParams{})
+
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{Count:
len(tt.rules), NetworkACLs: tt.rules}, nil)
+
+ updateParams := &cloudstack.UpdateNetworkACLItemParams{}
+ if tt.adopt != "" {
+
mockNetworkACL.EXPECT().NewUpdateNetworkACLItemParams(tt.adopt).Return(updateParams)
+
mockNetworkACL.EXPECT().UpdateNetworkACLItem(updateParams).Return(&cloudstack.UpdateNetworkACLItemResponse{},
tt.adoptErr)
+ }
+ createParams := &cloudstack.CreateNetworkACLParams{}
+ if tt.wantCreate {
+
mockNetworkACL.EXPECT().NewCreateNetworkACLParams(tt.protocol.IPProtocol()).Return(createParams)
+
mockNetworkACL.EXPECT().CreateNetworkACL(createParams).Return(&cloudstack.CreateNetworkACLResponse{Id:
"acl-new"}, tt.createErr)
+ }
+
+ lb := &loadBalancer{
+ name: "atestuid",
+ CloudStackClient:
&cloudstack.CloudStackClient{Network: mockNetwork, NetworkACL: mockNetworkACL},
+ }
+
+ updated, err := lb.updateNetworkACL(80, tt.protocol,
"net-1")
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(),
tt.wantErr) {
+ t.Fatalf("error = %v, want it to
contain %q", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !updated {
+ t.Errorf("updated = false, want true")
+ }
+ if tt.adopt != "" {
+ if sent := sentUpdateFields(updateParams);
!compareStringSlice(sent, []string{"reason"}) {
+ t.Errorf("adoption sent %v, want only
the reason", sent)
+ }
+ if reason, _ := updateParams.GetReason();
reason != testACLReason {
+ t.Errorf("adopted with reason %q, want
%q", reason, testACLReason)
+ }
+ }
+ if tt.wantCreate {
+ if reason, _ := createParams.GetReason();
reason != testACLReason {
+ t.Errorf("created with reason %q, want
%q", reason, testACLReason)
+ }
+ if networkID, _ := createParams.GetNetworkid();
networkID != "net-1" {
+ t.Errorf("created in network %q, want
net-1", networkID)
+ }
+ }
+ })
+ }
+}
+
+func TestDeleteNetworkACLRuleOwnership(t *testing.T) {
+ otherService := func(r *cloudstack.NetworkACL) { r.Reason =
networkACLReasonPrefix + "aotheruid" }
+ deleting := func(r *cloudstack.NetworkACL) { r.Reason = testACLReason;
r.State = "Deleting" }
+ firstErr := fmt.Errorf("first delete failed")
+
+ tests := []struct {
+ name string
+ protocol LoadBalancerProtocol
+ rules []*cloudstack.NetworkACL
+ deleteErrs map[string]error
+ wantDeleted []string
+ wantErr error
+ }{
+ {
+ name: "deletes only this service's rules",
+ rules: []*cloudstack.NetworkACL{
+ legacyACLRule("acl-other", otherService),
+ ownACLRule("acl-own"),
+ legacyACLRule("acl-legacy", nil),
+ legacyACLRule("acl-operator", func(r
*cloudstack.NetworkACL) { r.Cidrlist = "10.0.0.0/8" }),
+ legacyACLRule("acl-cks", func(r
*cloudstack.NetworkACL) { r.Protocol = "TCP" }),
+ },
+ wantDeleted: []string{"acl-own"},
+ },
+ {
+ name: "deletes its own rule again while it is
being deleted",
+ rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-own", deleting)},
+ wantDeleted: []string{"acl-own"},
+ },
+ {
+ name: "only logs a failure on a rule already
being deleted",
+ rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-own", deleting)},
+ deleteErrs: map[string]error{"acl-own":
fmt.Errorf("router unreachable")},
+ wantDeleted: []string{"acl-own"},
+ },
+ {
+ name: "tries every rule and reports the first
error",
+ rules:
[]*cloudstack.NetworkACL{ownACLRule("acl-own-1"), ownACLRule("acl-own-2")},
+ deleteErrs: map[string]error{"acl-own-1": firstErr,
"acl-own-2": fmt.Errorf("second delete failed")},
+ wantDeleted: []string{"acl-own-1", "acl-own-2"},
+ wantErr: firstErr,
+ },
+ {
+ name: "deletes a udp rule but not the tcp rule on
the same port",
+ protocol: LoadBalancerProtocolUDP,
+ rules: []*cloudstack.NetworkACL{
+ ownACLRule("acl-own-tcp"),
+ legacyACLRule("acl-own-udp", func(r
*cloudstack.NetworkACL) { r.Reason = testACLReason; r.Protocol = "udp" }),
+ },
+ wantDeleted: []string{"acl-own-udp"},
+ },
+ {
+ name: "deletes nothing when no rule is this service's",
+ rules:
[]*cloudstack.NetworkACL{legacyACLRule("acl-legacy", nil),
legacyACLRule("acl-other", otherService)},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockNetworkACL :=
cloudstack.NewMockNetworkACLServiceIface(ctrl)
+
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(&cloudstack.ListNetworkACLsParams{})
+
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{Count:
len(tt.rules), NetworkACLs: tt.rules}, nil)
+
+ var deleted []string
+ for _, id := range tt.wantDeleted {
+ deleteParams :=
&cloudstack.DeleteNetworkACLParams{}
+
mockNetworkACL.EXPECT().NewDeleteNetworkACLParams(id).Return(deleteParams)
+
mockNetworkACL.EXPECT().DeleteNetworkACL(deleteParams).DoAndReturn(func(*cloudstack.DeleteNetworkACLParams)
(*cloudstack.DeleteNetworkACLResponse, error) {
+ deleted = append(deleted, id)
+ if err := tt.deleteErrs[id]; err != nil
{
+ return nil, err
+ }
+ return
&cloudstack.DeleteNetworkACLResponse{}, nil
+ })
+ }
+
+ lb := &loadBalancer{
+ name: "atestuid",
+ CloudStackClient:
&cloudstack.CloudStackClient{NetworkACL: mockNetworkACL},
+ }
+
+ ok, err := lb.deleteNetworkACLRule(80, tt.protocol,
"net-1")
+ if err != tt.wantErr {
+ t.Errorf("error = %v, want %v", err, tt.wantErr)
+ }
+ if ok != (tt.wantErr == nil) {
+ t.Errorf("ok = %v, want %v", ok, tt.wantErr ==
nil)
+ }
+ if !compareStringSlice(deleted, tt.wantDeleted) {
+ t.Errorf("deleted %v, want %v", deleted,
tt.wantDeleted)
+ }
+ })
+ }
+}
+
+// Firewall rules are not shared like Network ACL rules (issue #107): Services
can only share a
+// public IP on different ports, so pruning touches the firewall rules of the
rule's own IP only.
+func TestPruneRulesScopesFirewallRulesToTheirIP(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
+ listParams := &cloudstack.ListFirewallRulesParams{}
+ deleteParams := &cloudstack.DeleteFirewallRuleParams{}
+ gomock.InOrder(
+
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
+
mockFirewall.EXPECT().ListFirewallRules(listParams).Return(&cloudstack.ListFirewallRulesResponse{
+ Count: 1,
+ FirewallRules: []*cloudstack.FirewallRule{{Id:
"fw-old", Protocol: "tcp", Startport: 80, Endport: 80, Cidrlist:
defaultAllowedCIDR}},
+ }, nil),
+
mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-old").Return(deleteParams),
+
mockFirewall.EXPECT().DeleteFirewallRule(deleteParams).Return(&cloudstack.DeleteFirewallRuleResponse{},
nil),
+
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-old").Return(&cloudstack.DeleteLoadBalancerRuleParams{}),
+
mockLB.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{},
nil),
+ )
+
+ lb := &loadBalancer{
+ CloudStackClient: &cloudstack.CloudStackClient{LoadBalancer:
mockLB, Firewall: mockFirewall},
+ name: "atestuid",
+ networkID: "net-1",
+ ipAddrID: "ip-current",
+ }
+ network := &cloudstack.Network{Id: "net-1", Service:
[]cloudstack.NetworkServiceInternal{{Name: "Firewall"}}}
+ obsolete := []obsoleteRule{{
+ rule: &cloudstack.LoadBalancerRule{Id: "rule-old", Name:
"atestuid-tcp-80", Publicipid: "ip-old", Publicport: "80", Protocol: "tcp",
Networkid: "net-1"},
+ protocol: LoadBalancerProtocolTCP,
+ tuple: portProtocol{"tcp", 80},
+ }}
+ desired := []desiredLBRule{{
+ name: "atestuid-tcp-443",
+ port: corev1.ServicePort{Port: 443, NodePort: 30443,
Protocol: corev1.ProtocolTCP},
+ protocol: LoadBalancerProtocolTCP,
+ }}
+
+ if err := lb.pruneRules(obsolete, desired, network); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ipID, _ := listParams.GetIpaddressid(); ipID != "ip-old" {
+ t.Errorf("firewall rules listed on IP %q, want the rule's own
ip-old", ipID)
+ }
+}
+
func TestGetLoadBalancer(t *testing.T) {
t.Run("load balancer with existing rules", func(t *testing.T) {
ctrl := gomock.NewController(t)
@@ -4621,6 +5062,7 @@ type ensureLBTestEnv struct {
network *cloudstack.MockNetworkServiceIface
firewall *cloudstack.MockFirewallServiceIface
address *cloudstack.MockAddressServiceIface
+ acl *cloudstack.MockNetworkACLServiceIface
service *corev1.Service
nodes []*corev1.Node
@@ -4637,6 +5079,7 @@ func newEnsureLBTestEnv(ctrl *gomock.Controller,
annotations map[string]string,
network: cloudstack.NewMockNetworkServiceIface(ctrl),
firewall: cloudstack.NewMockFirewallServiceIface(ctrl),
address: cloudstack.NewMockAddressServiceIface(ctrl),
+ acl: cloudstack.NewMockNetworkACLServiceIface(ctrl),
}
e.cs = &CSCloud{
@@ -4646,6 +5089,7 @@ func newEnsureLBTestEnv(ctrl *gomock.Controller,
annotations map[string]string,
Network: e.network,
Firewall: e.firewall,
Address: e.address,
+ NetworkACL: e.acl,
},
version: semver.Version{Major: 4, Minor: 22, Patch: 0},
}
@@ -5521,6 +5965,336 @@ func TestUpdateLoadBalancerPagination(t *testing.T) {
}
}
+// Regression test for issue #107 through a whole sync of a Service on a VPC
tier.
+func TestEnsureLoadBalancerNetworkACLOwnership(t *testing.T) {
+ vpcTier := &cloudstack.Network{
+ Id: "net-1",
+ Vpcid: "vpc-1",
+ Aclid: "acl-1",
+ Service: []cloudstack.NetworkServiceInternal{{Name:
"NetworkACL"}},
+ }
+ tierACLList := &cloudstack.NetworkACLList{Id: "acl-1", Name:
"tier-acl", Vpcid: "vpc-1"}
+ existingRule := func() *cloudstack.LoadBalancerRule {
+ return &cloudstack.LoadBalancerRule{
+ Id: "rule-1",
+ Name: "atestuid-tcp-80",
+ Publicip: "10.0.0.1",
+ Publicipid: "ip-1",
+ Publicport: "80",
+ Privateport: "30000",
+ Cidrlist: defaultAllowedCIDR,
+ Algorithm: "roundrobin",
+ Protocol: "tcp",
+ Networkid: "net-1",
+ }
+ }
+ otherServiceRule := legacyACLRule("acl-other", func(r
*cloudstack.NetworkACL) { r.Reason = networkACLReasonPrefix + "aotheruid" })
+
+ t.Run("a service sharing a port with another service opens it with its
own rule", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ env := newEnsureLBTestEnv(ctrl, nil,
[]corev1.ServicePort{{Port: 80, NodePort: 30000, Protocol: corev1.ProtocolTCP}})
+ env.expectHosts()
+ env.expectRules(existingRule())
+ env.network.EXPECT().GetNetworkByID("net-1",
gomock.Any()).Return(vpcTier, 1, nil).Times(2)
+ env.acl.EXPECT().GetNetworkACLListByID("acl-1",
gomock.Any()).Return(tierACLList, 1, nil)
+
env.acl.EXPECT().NewListNetworkACLsParams().Return(&cloudstack.ListNetworkACLsParams{})
+
env.acl.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{Count:
1, NetworkACLs: []*cloudstack.NetworkACL{otherServiceRule}}, nil)
+ createParams := &cloudstack.CreateNetworkACLParams{}
+
env.acl.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams)
+
env.acl.EXPECT().CreateNetworkACL(createParams).Return(&cloudstack.CreateNetworkACLResponse{Id:
"acl-own"}, nil)
+
+ if _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if reason, _ := createParams.GetReason(); reason !=
testACLReason {
+ t.Errorf("created with reason %q, want %q", reason,
testACLReason)
+ }
+ })
+
+ t.Run("a port change deletes only its own rule on the old port", func(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ env := newEnsureLBTestEnv(ctrl, nil,
[]corev1.ServicePort{{Port: 8080, NodePort: 30000, Protocol:
corev1.ProtocolTCP}})
+ env.expectHosts()
+ env.expectRules(existingRule())
+ env.network.EXPECT().GetNetworkByID("net-1",
gomock.Any()).Return(vpcTier, 1, nil).Times(2)
+
+ env.lb.EXPECT().NewCreateLoadBalancerRuleParams("roundrobin",
"atestuid-tcp-8080", 30000,
8080).Return(&cloudstack.CreateLoadBalancerRuleParams{})
+
env.lb.EXPECT().CreateLoadBalancerRule(gomock.Any()).Return(&cloudstack.CreateLoadBalancerRuleResponse{
+ Id: "rule-2", Name: "atestuid-tcp-8080", Publicip:
"10.0.0.1", Publicipid: "ip-1", Publicport: "8080", Protocol: "tcp", Networkid:
"net-1",
+ }, nil)
+
env.lb.EXPECT().NewAssignToLoadBalancerRuleParams("rule-2").Return(&cloudstack.AssignToLoadBalancerRuleParams{})
+
env.lb.EXPECT().AssignToLoadBalancerRule(gomock.Any()).Return(&cloudstack.AssignToLoadBalancerRuleResponse{},
nil)
+
+ tierRules := []*cloudstack.NetworkACL{otherServiceRule,
ownACLRule("acl-own-80")}
+ env.acl.EXPECT().GetNetworkACLListByID("acl-1",
gomock.Any()).Return(tierACLList, 1, nil)
+
env.acl.EXPECT().NewListNetworkACLsParams().Return(&cloudstack.ListNetworkACLsParams{}).Times(2)
+
env.acl.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{Count:
len(tierRules), NetworkACLs: tierRules}, nil).Times(2)
+
env.acl.EXPECT().NewCreateNetworkACLParams("tcp").Return(&cloudstack.CreateNetworkACLParams{})
+
env.acl.EXPECT().CreateNetworkACL(gomock.Any()).Return(&cloudstack.CreateNetworkACLResponse{Id:
"acl-own-8080"}, nil)
+ deleteParams := &cloudstack.DeleteNetworkACLParams{}
+
env.acl.EXPECT().NewDeleteNetworkACLParams("acl-own-80").Return(deleteParams)
+
env.acl.EXPECT().DeleteNetworkACL(deleteParams).Return(&cloudstack.DeleteNetworkACLResponse{},
nil)
+
+
env.lb.EXPECT().NewDeleteLoadBalancerRuleParams("rule-1").Return(&cloudstack.DeleteLoadBalancerRuleParams{})
+
env.lb.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{},
nil)
+
+ if _, err := env.cs.EnsureLoadBalancer(context.TODO(),
"test-cluster", env.service, env.nodes); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+}
+
+func TestEnsureLoadBalancerDeletedNetworkACLOwnership(t *testing.T) {
+ newEnv := func(t *testing.T) (*CSCloud,
*cloudstack.MockLoadBalancerServiceIface,
*cloudstack.MockNetworkACLServiceIface, *cloudstack.MockAddressServiceIface,
*cloudstack.ListNetworkACLsParams) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
+ mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
+ mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
+
+
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
+
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
+ Count: 1,
+ LoadBalancerRules: []*cloudstack.LoadBalancerRule{{
+ Id: "rule-1", Name: "atestuid-tcp-80",
Publicip: "10.0.0.1", Publicipid: "ip-1", Publicport: "80", Protocol: "tcp",
Networkid: "net-tier",
+ }},
+ }, nil)
+ mockNetwork.EXPECT().GetNetworkByID("net-tier",
gomock.Any()).Return(&cloudstack.Network{Id: "net-tier", Vpcid: "vpc-1"}, 1,
nil)
+
+ listParams := &cloudstack.ListNetworkACLsParams{}
+
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams)
+
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{
+ Count: 3,
+ NetworkACLs: []*cloudstack.NetworkACL{
+ legacyACLRule("acl-other", func(r
*cloudstack.NetworkACL) { r.Reason = networkACLReasonPrefix + "aotheruid" }),
+ ownACLRule("acl-own"),
+ legacyACLRule("acl-operator", func(r
*cloudstack.NetworkACL) { r.Cidrlist = "10.0.0.0/8" }),
+ },
+ }, nil)
+
+ cs := &CSCloud{client: &cloudstack.CloudStackClient{
+ LoadBalancer: mockLB, Network: mockNetwork, NetworkACL:
mockNetworkACL, Address: mockAddress,
+ }}
+ return cs, mockLB, mockNetworkACL, mockAddress, listParams
+ }
+ service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name:
"test-service", Namespace: "default", UID: "test-uid"}}
+
+ t.Run("deletes only its own ACL rule, in the rule's own network, then
the rule and the IP", func(t *testing.T) {
+ cs, mockLB, mockNetworkACL, mockAddress, listParams := newEnv(t)
+ deleteACL := &cloudstack.DeleteNetworkACLParams{}
+ deleteRule := &cloudstack.DeleteLoadBalancerRuleParams{}
+ release := &cloudstack.DisassociateIpAddressParams{}
+ gomock.InOrder(
+
mockNetworkACL.EXPECT().NewDeleteNetworkACLParams("acl-own").Return(deleteACL),
+
mockNetworkACL.EXPECT().DeleteNetworkACL(deleteACL).Return(&cloudstack.DeleteNetworkACLResponse{},
nil),
+
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-1").Return(deleteRule),
+
mockLB.EXPECT().DeleteLoadBalancerRule(deleteRule).Return(&cloudstack.DeleteLoadBalancerRuleResponse{},
nil),
+
mockAddress.EXPECT().NewDisassociateIpAddressParams("ip-1").Return(release),
+
mockAddress.EXPECT().DisassociateIpAddress(release).Return(&cloudstack.DisassociateIpAddressResponse{},
nil),
+ )
+
+ if err := cs.EnsureLoadBalancerDeleted(context.TODO(),
"test-cluster", service); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if networkID, _ := listParams.GetNetworkid(); networkID !=
"net-tier" {
+ t.Errorf("ACL rules listed on network %q, want the
rule's own net-tier", networkID)
+ }
+ })
+
+ t.Run("deletes the other rules, keeps the failing one and reports both
failures", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
+ mockNetworkACL := cloudstack.NewMockNetworkACLServiceIface(ctrl)
+ mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
+ mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
+
+ rule80 := &cloudstack.LoadBalancerRule{Id: "rule-80", Name:
"atestuid-tcp-80", Publicip: "10.0.0.1", Publicipid: "ip-1", Publicport: "80",
Protocol: "tcp", Networkid: "net-tier"}
+ rule8080 := &cloudstack.LoadBalancerRule{Id: "rule-8080", Name:
"atestuid-tcp-8080", Publicip: "10.0.0.1", Publicipid: "ip-1", Publicport:
"8080", Protocol: "tcp", Networkid: "net-tier"}
+ duplicate := &cloudstack.LoadBalancerRule{Id: "rule-dup", Name:
"atestuid-tcp-80", Publicip: "10.0.0.2", Publicipid: "ip-dup", Publicport:
"80", Protocol: "tcp", Networkid: "net-tier"}
+
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
+
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
+ Count: 3, LoadBalancerRules:
[]*cloudstack.LoadBalancerRule{rule80, rule8080, duplicate},
+ }, nil)
+
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(&cloudstack.ListFirewallRulesParams{})
+
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(nil,
fmt.Errorf("firewall API down"))
+
+ mockNetwork.EXPECT().GetNetworkByID("net-tier",
gomock.Any()).Return(&cloudstack.Network{Id: "net-tier", Vpcid: "vpc-1"}, 1,
nil).Times(2)
+ tierRules := []*cloudstack.NetworkACL{
+ ownACLRule("acl-own-80"),
+ legacyACLRule("acl-own-8080", func(r
*cloudstack.NetworkACL) { r.Reason = testACLReason; r.Startport, r.Endport =
"8080", "8080" }),
+ }
+
mockNetworkACL.EXPECT().NewListNetworkACLsParams().DoAndReturn(func()
*cloudstack.ListNetworkACLsParams {
+ return &cloudstack.ListNetworkACLsParams{}
+ }).Times(2)
+
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{Count:
len(tierRules), NetworkACLs: tierRules}, nil).Times(2)
+ deleting := map[*cloudstack.DeleteNetworkACLParams]string{}
+
mockNetworkACL.EXPECT().NewDeleteNetworkACLParams(gomock.Any()).DoAndReturn(func(id
string) *cloudstack.DeleteNetworkACLParams {
+ p := &cloudstack.DeleteNetworkACLParams{}
+ deleting[p] = id
+ return p
+ }).Times(2)
+
mockNetworkACL.EXPECT().DeleteNetworkACL(gomock.Any()).DoAndReturn(func(p
*cloudstack.DeleteNetworkACLParams) (*cloudstack.DeleteNetworkACLResponse,
error) {
+ if deleting[p] == "acl-own-80" {
+ return nil, fmt.Errorf("router unreachable")
+ }
+ return &cloudstack.DeleteNetworkACLResponse{}, nil
+ }).Times(2)
+ deleteRule := &cloudstack.DeleteLoadBalancerRuleParams{}
+
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-8080").Return(deleteRule)
+
mockLB.EXPECT().DeleteLoadBalancerRule(deleteRule).Return(&cloudstack.DeleteLoadBalancerRuleResponse{},
nil)
+
+ cs := &CSCloud{client: &cloudstack.CloudStackClient{
+ LoadBalancer: mockLB, Network: mockNetwork, NetworkACL:
mockNetworkACL, Firewall: mockFirewall, Address: mockAddress,
+ }}
+
+ err := cs.EnsureLoadBalancerDeleted(context.TODO(),
"test-cluster", service)
+ if err == nil || !strings.Contains(err.Error(), "router
unreachable") || !strings.Contains(err.Error(), "firewall API down") {
+ t.Fatalf("error = %v, want both the ACL rule and the
sweep failure", err)
+ }
+ })
+
+ t.Run("keeps the rule and the IP when its ACL rule cannot be deleted",
func(t *testing.T) {
+ cs, _, mockNetworkACL, _, _ := newEnv(t)
+ deleteACL := &cloudstack.DeleteNetworkACLParams{}
+
mockNetworkACL.EXPECT().NewDeleteNetworkACLParams("acl-own").Return(deleteACL)
+ mockNetworkACL.EXPECT().DeleteNetworkACL(deleteACL).Return(nil,
fmt.Errorf("router unreachable"))
+
+ err := cs.EnsureLoadBalancerDeleted(context.TODO(),
"test-cluster", service)
+ if err == nil || !strings.Contains(err.Error(), "router
unreachable") {
+ t.Fatalf("error = %v, want the ACL rule failure
reported for a retry", err)
+ }
+ })
+}
+
+// CloudStack reports a load balancer rule's network, but when it is missing
the network is looked
+// up from the rule's own public IP, not the one the load balancer was
published on.
+func TestEnsureLoadBalancerDeletedLooksUpTheNetworkOfEachRulesIP(t *testing.T)
{
+ service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name:
"test-service", Namespace: "default", UID: "test-uid"}}
+ ruleOn := func(id, name, port, ip, ipID string)
*cloudstack.LoadBalancerRule {
+ return &cloudstack.LoadBalancerRule{Id: id, Name: name,
Publicip: ip, Publicipid: ipID, Publicport: port, Protocol: "tcp"}
+ }
+
+ t.Run("each rule's network comes from its own IP", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
+ mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
+ mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
+
+
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
+
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
+ Count: 2,
+ LoadBalancerRules: []*cloudstack.LoadBalancerRule{
+ ruleOn("rule-80", "atestuid-tcp-80", "80",
"203.0.113.1", "ip-a"),
+ ruleOn("rule-8080", "atestuid-tcp-8080",
"8080", "203.0.113.2", "ip-b"),
+ },
+ }, nil)
+ mockAddress.EXPECT().GetPublicIpAddressByID("ip-a",
gomock.Any()).Return(&cloudstack.PublicIpAddress{Id: "ip-a",
Associatednetworkid: "net-a"}, 1, nil)
+ mockAddress.EXPECT().GetPublicIpAddressByID("ip-b",
gomock.Any()).Return(&cloudstack.PublicIpAddress{Id: "ip-b",
Associatednetworkid: "net-b"}, 1, nil)
+ mockNetwork.EXPECT().GetNetworkByID("net-a",
gomock.Any()).Return(&cloudstack.Network{Id: "net-a"}, 1, nil).Times(2)
+ mockNetwork.EXPECT().GetNetworkByID("net-b",
gomock.Any()).Return(&cloudstack.Network{Id: "net-b"}, 1, nil).Times(2)
+
mockFirewall.EXPECT().NewListFirewallRulesParams().DoAndReturn(func()
*cloudstack.ListFirewallRulesParams {
+ return &cloudstack.ListFirewallRulesParams{}
+ }).Times(2)
+
mockFirewall.EXPECT().ListFirewallRules(gomock.Any()).Return(&cloudstack.ListFirewallRulesResponse{},
nil).Times(2)
+
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams(gomock.Any()).DoAndReturn(func(string)
*cloudstack.DeleteLoadBalancerRuleParams {
+ return &cloudstack.DeleteLoadBalancerRuleParams{}
+ }).Times(2)
+
mockLB.EXPECT().DeleteLoadBalancerRule(gomock.Any()).Return(&cloudstack.DeleteLoadBalancerRuleResponse{},
nil).Times(2)
+ release := &cloudstack.DisassociateIpAddressParams{}
+
mockAddress.EXPECT().NewDisassociateIpAddressParams("ip-a").Return(release)
+
mockAddress.EXPECT().DisassociateIpAddress(release).Return(&cloudstack.DisassociateIpAddressResponse{},
nil)
+
+ cs := &CSCloud{client: &cloudstack.CloudStackClient{
+ LoadBalancer: mockLB, Network: mockNetwork, Firewall:
mockFirewall, Address: mockAddress,
+ }}
+
+ if err := cs.EnsureLoadBalancerDeleted(context.TODO(),
"test-cluster", service); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("a failed lookup keeps the rule and is reported", func(t
*testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
+
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
+
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
+ Count: 1,
+ LoadBalancerRules:
[]*cloudstack.LoadBalancerRule{ruleOn("rule-80", "atestuid-tcp-80", "80",
"203.0.113.1", "ip-a")},
+ }, nil)
+ mockAddress.EXPECT().GetPublicIpAddressByID("ip-a",
gomock.Any()).Return(nil, -1, fmt.Errorf("API unavailable"))
+
+ cs := &CSCloud{client:
&cloudstack.CloudStackClient{LoadBalancer: mockLB, Address: mockAddress}}
+
+ err := cs.EnsureLoadBalancerDeleted(context.TODO(),
"test-cluster", service)
+ if err == nil || !strings.Contains(err.Error(), "API
unavailable") {
+ t.Fatalf("error = %v, want the lookup failure", err)
+ }
+ })
+}
+
+func TestEnsureLoadBalancerDeletedFirewallUsesRuleNetwork(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ mockLB := cloudstack.NewMockLoadBalancerServiceIface(ctrl)
+ mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
+ mockFirewall := cloudstack.NewMockFirewallServiceIface(ctrl)
+ mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
+
+
mockLB.EXPECT().NewListLoadBalancerRulesParams().Return(&cloudstack.ListLoadBalancerRulesParams{})
+
mockLB.EXPECT().ListLoadBalancerRules(gomock.Any()).Return(&cloudstack.ListLoadBalancerRulesResponse{
+ Count: 1,
+ LoadBalancerRules: []*cloudstack.LoadBalancerRule{{
+ Id: "rule-1", Name: "atestuid-tcp-80", Publicip:
"203.0.113.1", Publicipid: "ip-1", Publicport: "80", Protocol: "tcp",
Networkid: "net-iso",
+ }},
+ }, nil)
+ mockNetwork.EXPECT().GetNetworkByID("net-iso",
gomock.Any()).Return(&cloudstack.Network{Id: "net-iso"}, 1, nil)
+ listParams := &cloudstack.ListFirewallRulesParams{}
+ deleteFirewall := &cloudstack.DeleteFirewallRuleParams{}
+ deleteRule := &cloudstack.DeleteLoadBalancerRuleParams{}
+ release := &cloudstack.DisassociateIpAddressParams{}
+ gomock.InOrder(
+
mockFirewall.EXPECT().NewListFirewallRulesParams().Return(listParams),
+
mockFirewall.EXPECT().ListFirewallRules(listParams).Return(&cloudstack.ListFirewallRulesResponse{
+ Count: 1,
+ FirewallRules: []*cloudstack.FirewallRule{{Id: "fw-1",
Protocol: "tcp", Startport: 80, Endport: 80, Cidrlist: defaultAllowedCIDR}},
+ }, nil),
+
mockFirewall.EXPECT().NewDeleteFirewallRuleParams("fw-1").Return(deleteFirewall),
+
mockFirewall.EXPECT().DeleteFirewallRule(deleteFirewall).Return(&cloudstack.DeleteFirewallRuleResponse{},
nil),
+
mockLB.EXPECT().NewDeleteLoadBalancerRuleParams("rule-1").Return(deleteRule),
+
mockLB.EXPECT().DeleteLoadBalancerRule(deleteRule).Return(&cloudstack.DeleteLoadBalancerRuleResponse{},
nil),
+
mockAddress.EXPECT().NewDisassociateIpAddressParams("ip-1").Return(release),
+
mockAddress.EXPECT().DisassociateIpAddress(release).Return(&cloudstack.DisassociateIpAddressResponse{},
nil),
+ )
+
+ cs := &CSCloud{client: &cloudstack.CloudStackClient{
+ LoadBalancer: mockLB, Network: mockNetwork, Firewall:
mockFirewall, Address: mockAddress,
+ }}
+ service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name:
"test-service", Namespace: "default", UID: "test-uid"}}
+
+ if err := cs.EnsureLoadBalancerDeleted(context.TODO(), "test-cluster",
service); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ipID, _ := listParams.GetIpaddressid(); ipID != "ip-1" {
+ t.Errorf("firewall rules listed on IP %q, want ip-1", ipID)
+ }
+}
+
// A Network ACL rule is scoped to a network, so an obsolete rule left over in
another network
// is not protected by a desired port that happens to share its protocol and
port.
func TestPruneRulesScopesNetworkACLsToTheirNetwork(t *testing.T) {
@@ -5591,6 +6365,7 @@ func TestPruneRulesScopesNetworkACLsToTheirNetwork(t
*testing.T) {
Network: m.network,
Firewall: m.firewall,
},
+ name: "atestuid",
networkID: "net-new",
ipAddrID: "ip-current",
}, m
@@ -5601,9 +6376,12 @@ func TestPruneRulesScopesNetworkACLsToTheirNetwork(t
*testing.T) {
gomock.InOrder(
m.acl.EXPECT().NewListNetworkACLsParams().Return(listParams),
m.acl.EXPECT().ListNetworkACLs(gomock.Any()).Return(&cloudstack.ListNetworkACLsResponse{
- Count: 1,
+ Count: 4,
NetworkACLs: []*cloudstack.NetworkACL{
- {Id: "acl-rule-old", Protocol: "tcp",
Startport: "80", Endport: "80"},
+ legacyACLRule("acl-other-service",
func(r *cloudstack.NetworkACL) { r.Reason = networkACLReasonPrefix +
"aotheruid" }),
+ ownACLRule("acl-rule-old"),
+ legacyACLRule("acl-legacy", nil),
+ legacyACLRule("acl-operator", func(r
*cloudstack.NetworkACL) { r.Cidrlist = "10.0.0.0/8" }),
},
}, nil),
m.acl.EXPECT().NewDeleteNetworkACLParams("acl-rule-old").Return(&cloudstack.DeleteNetworkACLParams{}),
diff --git a/docs/development.md b/docs/development.md
index 9727f2c6..ce78e928 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -381,6 +381,11 @@ Each test creates its own namespace and cleans up after
itself. Because load
balancer provisioning is asynchronous, all assertions poll rather than
assuming immediate consistency.
+The VPC tests count every rule on their port in the tier's ACL list, and some
+create rules of their own on 8080 and 8085 the way an operator would, deleting
+them on cleanup. They stop at once if a rule for their port is left over from
+an earlier run.
+
### Known limitation: provider IDs
kind starts kubelet with `--provider-id=kind://docker/<cluster>/<node>`, and
@@ -420,7 +425,8 @@ so a run is reproducible; avoid floating tags like `latest`.
| Nodes keep the uninitialized taint; CCM logs `provided node ip for node
"..." is not valid` | The CloudStack VM's NIC IP does not match the IP kubelet
registered with. Recreate the VM with `ipaddress=` set to the kind node's
docker IP. |
| Services stay `<pending>`; CCM logs `none of the hosts matched the list of
VMs retrieved from CS API` | No CloudStack VM has a name matching a Kubernetes
node name. |
| CCM logs `found hosts that belong to different networks` | VMs matching the
node names exist on more than one network — typically leftovers from a previous
scenario. |
-| No ACL rules are created on a VPC network | The tier uses `default_allow` or
`default_deny`. The CCM only manages rules on custom ACL lists. |
+| No ACL rules are created on a VPC network | The tier uses `default_allow` or
`default_deny`; the CCM only manages rules on custom ACL lists. Or the list
already has an ingress rule for that port that the CCM did not create; the CCM
then relies on that rule and adds none of its own. |
+| A VPC test fails at once with `leftover ACL rule` | A rule for its port
survived an earlier run. Delete it from the tier's ACL list and rerun. |
| CoreDNS stuck `Pending` | Expected until the CCM removes the uninitialized
taint. If it persists, the CCM is not working — check its logs. |
| `kubectl logs`/`exec` fail after nodes initialize | The API server is
preferring the Hostname address, which the CCM set to the CloudStack instance
hostname. Use `kubelet-preferred-address-types: InternalIP` as the provided
kind config does. |
| Simulator never becomes ready | It runs `mvn jetty:run` and fetches from
Maven Central at startup. Check `docker logs cloudstack-simulator`. |
diff --git a/test/e2e/framework.go b/test/e2e/framework.go
index bed2cea8..163992ae 100644
--- a/test/e2e/framework.go
+++ b/test/e2e/framework.go
@@ -559,6 +559,60 @@ func (f *Framework) ACLRules(aclListID string)
([]*cloudstack.NetworkACL, error)
return resp.NetworkACLs, nil
}
+// aclRuleMarker is the reason the CCM gives the Network ACL rules it creates
for a load balancer.
+func aclRuleMarker(lbName string) string {
+ return "Managed by the CloudStack Kubernetes Provider for " + lbName
+}
+
+// ACLRule fetches one rule of an ACL list, or returns nil when the rule no
longer exists.
+func (f *Framework) ACLRule(aclListID, id string) (*cloudstack.NetworkACL,
error) {
+ rules, err := f.ACLRules(aclListID)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range rules {
+ if r.Id == id {
+ return r, nil
+ }
+ }
+ return nil, nil
+}
+
+// CreateACLRule adds an ingress tcp rule with no reason for one port to an
ACL list through the
+// CloudStack API, the way an operator would, and returns its ID. Cleanup
deletes the rule unless
+// it is already gone. The CIDR is always sent, because CloudStack 4.17 and
later stores
+// 0.0.0.0/0,::/0 when it is left out.
+func (f *Framework) CreateACLRule(aclListID string, port int, cidr string)
string {
+ f.T.Helper()
+ p := f.CS.NetworkACL.NewCreateNetworkACLParams("tcp")
+ p.SetAclid(aclListID)
+ p.SetAction("Allow")
+ p.SetCidrlist([]string{cidr})
+ p.SetStartport(port)
+ p.SetEndport(port)
+ p.SetTraffictype("Ingress")
+ resp, err := f.CS.NetworkACL.CreateNetworkACL(p)
+ if err != nil {
+ f.T.Fatalf("creating ACL rule for port %d: %v", port, err)
+ }
+
+ id := resp.Id
+ f.T.Cleanup(func() {
+ aclRule, err := f.ACLRule(aclListID, id)
+ if err != nil {
+ f.T.Errorf("looking up ACL rule %s for cleanup: %v",
id, err)
+ return
+ }
+ if aclRule == nil {
+ return
+ }
+ if _, err :=
f.CS.NetworkACL.DeleteNetworkACL(f.CS.NetworkACL.NewDeleteNetworkACLParams(id));
err != nil {
+ f.T.Errorf("deleting ACL rule %s: %v", id, err)
+ }
+ })
+ return id
+}
+
// PublicIP fetches a public IP address record by its ID.
func (f *Framework) PublicIP(id string) (*cloudstack.PublicIpAddress, error) {
p := f.CS.Address.NewListPublicIpAddressesParams()
diff --git a/test/e2e/vpc_test.go b/test/e2e/vpc_test.go
index 55392178..e3149539 100644
--- a/test/e2e/vpc_test.go
+++ b/test/e2e/vpc_test.go
@@ -24,10 +24,12 @@ package e2e
import (
"context"
"os"
+ "slices"
"strconv"
"strings"
"testing"
+ "github.com/apache/cloudstack-go/v2/cloudstack"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -55,8 +57,8 @@ func vpcFramework(t *testing.T) (*Framework, string, string) {
// TestVPC_LoadBalancer covers the VPC path end to end: the LB rule is
// created, the public IP is associated with the VPC, ingress traffic is
-// allowed via a Network ACL rule on the custom ACL list (not a firewall
-// rule), and everything is cleaned up on delete.
+// allowed via a Network ACL rule of the Service's own on the custom ACL list
+// (not a firewall rule), and everything is cleaned up on delete.
func TestVPC_LoadBalancer(t *testing.T) {
f, aclID, vpcID := vpcFramework(t)
@@ -85,7 +87,8 @@ func TestVPC_LoadBalancer(t *testing.T) {
if r.Startport == "80" && r.Endport == "80" &&
strings.EqualFold(r.Protocol, "tcp") &&
strings.EqualFold(r.Action, "Allow") &&
- strings.EqualFold(r.Traffictype,
"Ingress") {
+ strings.EqualFold(r.Traffictype,
"Ingress") &&
+ r.Reason == aclRuleMarker(lbName) {
return true, nil
}
}
@@ -163,8 +166,8 @@ func countACLRules(f *Framework, aclID, port string) (int,
error) {
func TestVPC_ACLRuleNotDuplicatedOnResync(t *testing.T) {
f, aclID, _ := vpcFramework(t)
- // An ACL rule belongs to the tier, not the service, so sharing port 80
with
- // TestVPC_LoadBalancer would blur the count.
+ // Every ACL rule on the port is counted, so no other Service may hold
one on
+ // it while this test runs; the VPC tests run one at a time and clean
up.
const port = "8080"
svc := f.CreateLBService(func(s *corev1.Service) {
s.Spec.Ports = []corev1.ServicePort{
@@ -270,9 +273,9 @@ func TestVPC_ExplicitLoadBalancerIPReleased(t *testing.T) {
func TestVPC_ProxyProtocolACL(t *testing.T) {
f, aclID, _ := vpcFramework(t)
- // An ACL rule belongs to the tier, so this test uses a port of its
own. Note
- // that 8081 is the virtual router's HAProxy stats port, which
CloudStack
- // refuses to load balance.
+ // Every ACL rule on the port is counted, so no other Service may hold
one on
+ // it while this test runs. Note that 8081 is the virtual router's
HAProxy
+ // stats port, which CloudStack refuses to load balance.
const servicePort int32 = 8085
port := strconv.Itoa(int(servicePort))
svc := f.CreateLBService(func(s *corev1.Service) {
@@ -305,7 +308,7 @@ func TestVPC_ProxyProtocolACL(t *testing.T) {
}
}
- // Turning the annotation off keeps the one ACL rule: both protocols
share it.
+ // Turning the annotation off keeps the Service's one ACL rule: both
protocols share it.
f.UpdateService(svc, func(s *corev1.Service) {
delete(s.Annotations, annotationProxyProtocol)
})
@@ -326,3 +329,189 @@ func TestVPC_ProxyProtocolACL(t *testing.T) {
t.Errorf("ACL rules for port %s = %d, want exactly 1 across the
toggle", port, n)
}
}
+
+// aclRulesOnPort returns the ingress tcp ACL rules on the list for one port.
+func aclRulesOnPort(f *Framework, aclID, port string)
([]*cloudstack.NetworkACL, error) {
+ rules, err := f.ACLRules(aclID)
+ if err != nil {
+ return nil, err
+ }
+ var onPort []*cloudstack.NetworkACL
+ for _, r := range rules {
+ if r.Startport == port && r.Endport == port &&
strings.EqualFold(r.Protocol, "tcp") &&
+ strings.EqualFold(r.Traffictype, "Ingress") {
+ onPort = append(onPort, r)
+ }
+ }
+ return onPort, nil
+}
+
+// hasACLRuleWithReason reports whether one of the rules carries the reason.
+func hasACLRuleWithReason(rules []*cloudstack.NetworkACL, reason string) bool {
+ for _, r := range rules {
+ if r.Reason == reason {
+ return true
+ }
+ }
+ return false
+}
+
+// requireNoACLRules stops the test when a single-port rule for the port,
whatever its protocol or
+// direction, is left over from an earlier run, since the tests below count
those rules. A rule
+// for a port range is not reported: neither these tests nor the controller
look at one.
+func requireNoACLRules(f *Framework, aclID, port string) {
+ f.T.Helper()
+ rules, err := f.ACLRules(aclID)
+ if err != nil {
+ f.T.Fatalf("listing ACL rules for port %s: %v", port, err)
+ }
+ leftover := false
+ for _, r := range rules {
+ if r.Startport == port && r.Endport == port {
+ f.T.Errorf("leftover ACL rule %s for port %s (%s %s,
reason %q); delete it before rerunning",
+ r.Id, port, r.Traffictype, r.Protocol, r.Reason)
+ leftover = true
+ }
+ }
+ if leftover {
+ f.T.FailNow()
+ }
+}
+
+// TestVPC_ACLRulePerService is a regression test for issue #107: two Services
+// on one tier exposing the same port each get an ACL rule of their own, so
+// deleting one Service leaves the port open for the other. Earlier releases
+// shared one rule between them and deleted it with the first Service, which
+// this test reports as the port no longer being open.
+func TestVPC_ACLRulePerService(t *testing.T) {
+ f, aclID, _ := vpcFramework(t)
+
+ const port = "80"
+ requireNoACLRules(f, aclID, port)
+
+ first := f.CreateLBService(nil)
+ second := f.CreateLBService(func(s *corev1.Service) { s.Name =
"e2e-second" })
+ f.WaitForIngressIP(first)
+ f.WaitForIngressIP(second)
+ firstReason := aclRuleMarker(defaultLoadBalancerName(first))
+ secondReason := aclRuleMarker(defaultLoadBalancerName(second))
+
+ f.Eventually(lbSyncTimeout, lbSyncInterval, "port "+port+" to be open
on the tier",
+ func() (bool, error) {
+ rules, err := aclRulesOnPort(f, aclID, port)
+ return opensPortToAll(rules), err
+ })
+
+ f.DeleteServiceAndWait(first)
+
+ rules, err := aclRulesOnPort(f, aclID, port)
+ if err != nil {
+ t.Fatalf("listing ACL rules: %v", err)
+ }
+ if !opensPortToAll(rules) {
+ t.Fatalf("port %s is no longer open on the tier after deleting
one of the two Services using it", port)
+ }
+ if !hasACLRuleWithReason(rules, secondReason) {
+ t.Errorf("the remaining Service has no ACL rule of its own for
port %s", port)
+ }
+ f.Eventually(lbSyncTimeout, lbSyncInterval, "the deleted Service's ACL
rule to be removed",
+ func() (bool, error) {
+ rules, err := aclRulesOnPort(f, aclID, port)
+ return !hasACLRuleWithReason(rules, firstReason), err
+ })
+}
+
+// opensPortToAll reports whether one of the rules allows the port from
anywhere and is not being
+// deleted.
+func opensPortToAll(rules []*cloudstack.NetworkACL) bool {
+ for _, r := range rules {
+ if strings.EqualFold(r.Action, "Allow") && r.State !=
"Deleting" && hasCIDR(r.Cidrlist, "0.0.0.0/0") {
+ return true
+ }
+ }
+ return false
+}
+
+// hasCIDR reports whether a rule's CIDR list holds exactly the given entry.
CloudStack has
+// reported these lists both comma and space separated.
+func hasCIDR(cidrList, cidr string) bool {
+ entries := strings.FieldsFunc(cidrList, func(r rune) bool { return r ==
',' || r == ' ' })
+ return slices.Contains(entries, cidr)
+}
+
+// TestVPC_LegacyACLRuleAdopted covers the upgrade path of issue #107: a rule
+// shaped like the ones earlier releases created is adopted by the Service that
+// needs its port, not duplicated, and is removed with that Service.
+func TestVPC_LegacyACLRuleAdopted(t *testing.T) {
+ f, aclID, _ := vpcFramework(t)
+
+ const servicePort int32 = 8080
+ port := strconv.Itoa(int(servicePort))
+ requireNoACLRules(f, aclID, port)
+
+ legacyID := f.CreateACLRule(aclID, int(servicePort), "0.0.0.0/0")
+ svc := f.CreateLBService(func(s *corev1.Service) {
+ s.Spec.Ports = []corev1.ServicePort{
+ {Name: "http", Port: servicePort, Protocol:
corev1.ProtocolTCP},
+ }
+ })
+ reason := aclRuleMarker(defaultLoadBalancerName(svc))
+ f.WaitForIngressIP(svc)
+
+ f.Eventually(lbSyncTimeout, lbSyncInterval, "the existing ACL rule to
be adopted",
+ func() (bool, error) {
+ r, err := f.ACLRule(aclID, legacyID)
+ return r != nil && r.Reason == reason, err
+ })
+ if n, err := countACLRules(f, aclID, port); err != nil || n != 1 {
+ t.Errorf("ACL rules for port %s = %d (error %v), want exactly
1; the Service added a rule instead of adopting", port, n, err)
+ }
+
+ f.DeleteServiceAndWait(svc)
+ f.Eventually(lbSyncTimeout, lbSyncInterval, "the adopted ACL rule to be
removed with its Service",
+ func() (bool, error) {
+ r, err := f.ACLRule(aclID, legacyID)
+ return r == nil, err
+ })
+}
+
+// TestVPC_OperatorACLRuleKept checks that the controller leaves an ACL rule
+// someone else made for its port alone (issue #107): it adds no rule of its
+// own and keeps the other rule when the Service is deleted.
+func TestVPC_OperatorACLRuleKept(t *testing.T) {
+ f, aclID, _ := vpcFramework(t)
+
+ const servicePort int32 = 8085
+ const operatorCIDR = "10.0.0.0/8"
+ port := strconv.Itoa(int(servicePort))
+ requireNoACLRules(f, aclID, port)
+
+ operatorID := f.CreateACLRule(aclID, int(servicePort), operatorCIDR)
+ svc := f.CreateLBService(func(s *corev1.Service) {
+ s.Spec.Ports = []corev1.ServicePort{
+ {Name: "http", Port: servicePort, Protocol:
corev1.ProtocolTCP},
+ }
+ })
+ f.WaitForIngressIP(svc)
+
+ rules, err := aclRulesOnPort(f, aclID, port)
+ if err != nil {
+ t.Fatalf("listing ACL rules: %v", err)
+ }
+ if len(rules) != 1 || rules[0].Id != operatorID {
+ t.Errorf("ACL rules for port %s = %d, want only the hand-made
rule %s", port, len(rules), operatorID)
+ }
+
+ f.DeleteServiceAndWait(svc)
+
+ r, err := f.ACLRule(aclID, operatorID)
+ if err != nil {
+ t.Fatalf("looking up the hand-made ACL rule: %v", err)
+ }
+ if r == nil {
+ t.Fatalf("the hand-made ACL rule for port %s was deleted with
the Service", port)
+ }
+ if r.Cidrlist != operatorCIDR || r.Reason != "" {
+ t.Errorf("the hand-made ACL rule changed: cidrlist %q, reason
%q", r.Cidrlist, r.Reason)
+ }
+}