Skip to content

Fix protocol changes on existing load balancer rules - #104

Open
vishesh92 wants to merge 1 commit into
mainfrom
fix-protocol-toggle
Open

vishesh92 wants to merge 1 commit into
mainfrom
fix-protocol-toggle

Conversation

@vishesh92

@vishesh92 vishesh92 commented Aug 26, 2026 •

Copy link
Copy Markdown
Member

Fixes #2.

Toggling service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol on an
existing LoadBalancer service wedges reconciliation permanently:

error creating load balancer rule a192...-tcp-proxy-80: CloudStack API error 537:
The range specified, 80-80, conflicts with rule FirewallRule {...} which has 80-80

Cause: rule names embed the protocol (<lb>-<protocol>-<port>) and rules are looked
up by name, so a protocol change misses the lookup and tries to create a rule on a port
the old rule still holds. The in-place update was already written but unreachable — a
name-keyed lookup can never return a rule whose protocol differs from the one requested.

Changes

  • findLoadBalancerRule matches on an exact name, then falls back to
    (public IP, IP protocol, public port) — the tuple CloudStack enforces uniqueness on.
    A proxy-protocol toggle now resolves to the existing rule and updates it in place.
    Rules on a stale IP are pruned rather than matched, which used to strand their
    firewall rule.
  • Renames the rule via SetName so the name stops contradicting its protocol.
  • Reconciles in three phases: resolve, prune, apply. Rules blocking a needed port are
    deleted before the creates, everything else after, so a cleanup failure can't take a
    service down. Blocking is keyed on port alone — detectRulesConflict never exempts
    LoadBalancing pairs with differing protocols.

Also fixed:

  • Proxy protocol was unusable on VPC tiers, independently of [OLD] Changing from TCP to TCP Proxy doesn't work #2: updateNetworkACL
    created ACLs with CSProtocol() (tcp-proxy, which CloudStack rejects) while
    filtering with IPProtocol(). Both now use IPProtocol().
  • Multi-CIDR services churned every sync — an existing rule's Cidrlist was split on
    " " while CloudStack returns it comma-separated.

Copilot AI lite review requested due to automatic review settings August 26, 2026 12:22
@codecov-commenter

codecov-commenter commented Aug 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.23767% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.04%. Comparing base (5147f76) to head (bf3a6d9).

Files with missing lines Patch % Lines
cloudstack_loadbalancer.go 89.18% 15 Missing and 9 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #104      +/-   ##
==========================================
+ Coverage   58.70%   67.04%   +8.33%     
==========================================
  Files           5        5              
  Lines        1109     1250     +141     
==========================================
+ Hits          651      838     +187     
+ Misses        425      365      -60     
- Partials       33       47      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes reconciliation getting wedged when toggling service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol on an existing LoadBalancer Service by resolving existing CloudStack LB rules by their uniqueness tuple (public IP, IP protocol, public port) and updating rules in-place (including renaming) rather than attempting conflicting creates. Also includes related robustness fixes around CIDR list comparison, VPC ACL protocol handling, and management-server version parsing.

Changes:

  • Reworks LB rule reconciliation into resolve/prune/apply phases, with rule lookup falling back from name to (public IP, IP protocol, public port) to support protocol toggles.
  • Normalizes CIDR list parsing for rule comparisons and gates CIDR updates on CloudStack version support.
  • Hardens management server version parsing (avoids panics on short versions) and expands unit test coverage, including regression tests for issue #2.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
README.md Documents that proxy-protocol toggling updates the rule in-place without port interruption.
cloudstack.go Makes management-server version parsing resilient to short/4-part version strings.
cloudstack_test.go Adds coverage for short/empty version strings to prevent panics and validate parsing behavior.
cloudstack_loadbalancer.go Implements tuple-based LB rule resolution, phased prune/apply reconciliation, CIDR list normalization, and VPC ACL protocol fix.
cloudstack_loadbalancer_test.go Adds tests for CIDR splitting, tuple-based rule matching, in-place protocol toggles, phased pruning behavior, and VPC ACL behavior.
Suppressed comments (1)

cloudstack_loadbalancer.go:890

  • When a rule must be recreated, checkLoadBalancerRule deletes it in CloudStack but leaves it in lb.rules. That leftover entry is then treated as obsolete and pruned, which can trigger a second DeleteLoadBalancerRule call (and potentially fail reconciliation with a "not found"/API error) even though the rule was already deleted in the resolve phase.

Remove the rule from lb.rules immediately after a successful delete so it can’t be pruned again later in the same reconciliation.

		// Delete the load balancer rule so we can create a new one using the new values.
		if err := lb.deleteLoadBalancerRule(lbRule); err != nil {
			return nil, false, err
		}
		return nil, false, nil

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@vishesh92
vishesh92 force-pushed the fix-protocol-toggle branch from 345a563 to bc832c4 Compare August 27, 2026 07:59
Copilot AI review requested due to automatic review settings August 27, 2026 07:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings September 17, 2026 07:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved stale-IP/ACL cleanup cases and mismatched ACL mock expectations affect correctness and test reliability.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

cloudstack_loadbalancer_test.go:3218

  • As in the earlier ACL test, these expectations omit the variadic project option even though updateNetworkACL passes cloudstack.WithProject(lb.projectID) to both calls. The mock therefore does not match the production invocation and this added test fails before checking the existing-rule path.
			mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
			mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, nil),
  • Files reviewed: 5/5 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment thread cloudstack_loadbalancer.go
Comment thread cloudstack_loadbalancer.go Outdated
Comment thread cloudstack_loadbalancer.go Outdated
Comment thread cloudstack_loadbalancer_test.go Outdated
Comment thread cloudstack.go Outdated
Copilot AI review requested due to automatic review settings September 17, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unknown-protocol conflicts and cross-network stale-rule cleanup can still wedge reconciliation or leave ACL resources behind.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

cloudstack_loadbalancer.go:759

  • An obsolete rule with an unknown protocol is skipped here, but partitionObsoleteRules uses the public port alone to decide whether a leftover rule blocks a create. If this rule is on the current IP and holds a port needed by a new desired rule, the subsequent create still hits CloudStack's port-conflict error and reconciliation remains wedged. Parse and classify the port before rejecting the protocol so such a rule is deleted before the create, while handling its unknown firewall/ACL protocol separately.
		protocol := ProtocolFromLoadBalancer(lbRule.Protocol)
		if protocol == LoadBalancerProtocolInvalid {
			klog.Errorf("Skipping obsolete load balancer rule %v with unknown protocol %v", lbRule.Name, lbRule.Protocol)
			continue

cloudstack_loadbalancer.go:824

  • This cleanup uses the network of the service currently being reconciled for every obsolete rule. A stale rule can be attached to an IP in a different network/VPC; in that case claimed is also keyed without network identity, so a desired tuple in the current network can preserve the old network's ACL, or this branch can query/delete the wrong ACL list. Resolve the obsolete rule's network (for example from its IP/Networkid) and include that network in the claim before choosing the firewall/ACL cleanup path.
		} else if isNetworkACLSupported(network.Service) {
			// ACL rules belong to the network rather than an IP, so the claim always applies.
			if claimed[o.tuple] {
				klog.V(4).Infof("Keeping Network ACL rules of obsolete load balancer rule %v (%v:%v): still claimed by a service port", lbRule.Name, protocol, port)
			} else {

cloudstack_loadbalancer.go:690

  • lb.ipAddrID is not guaranteed to be the service's current/desired IP: getLoadBalancer assigns it from each listed rule, so when rules with different names exist on old and current IPs, the API response order can make the stale IP win. This filter would then match the stale rule and prune the actual current-IP rule, contrary to the intended stale-IP handling. Select the target from spec.loadBalancerIP/published ingress (or otherwise validate the selected IP) before using it here.
// Only rules on the IP being reconciled towards are eligible; a rule on any other IP is left
// for the prune pass, which also cleans up the firewall rules it leaves behind.
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread README.md
Copilot AI review requested due to automatic review settings September 17, 2026 12:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

VPC cleanup can mis-scope Network ACLs when an obsolete rule omits its network ID.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread cloudstack_loadbalancer.go Outdated
Copilot AI review requested due to automatic review settings September 18, 2026 06:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Explicit stale-IP reconciliation and cross-network firewall/ACL cleanup still have correctness gaps.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

cloudstack_loadbalancer.go:451

  • When service.Spec.LoadBalancerIP is set but the API returns only rules on the old IP, preferredIP is never observed in this loop, so lb.ipAddr/lb.ipAddrID remain stale. EnsureLoadBalancer then sees hasLoadBalancerIP() as true and skips getLoadBalancerIP, causing the newly resolved rules to be created on the old address instead of the requested one; clear/re-resolve the selected IP when no rule matches the explicit preferred address before resolving rules.
		if lb.ipAddr == "" || lb.ipAddr != preferredIP {
			lb.ipAddr = lbRule.Publicip
			lb.ipAddrID = lbRule.Publicipid
		}
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread cloudstack_loadbalancer.go Outdated
Copilot AI review requested due to automatic review settings September 18, 2026 07:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Network ACL pruning can remove a tier-shared opening needed by another service, and the new VPC E2E assertion can race ACL reconciliation.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread cloudstack_loadbalancer.go
Comment thread cloudstack_loadbalancer.go
Comment thread test/e2e/vpc_test.go

@kiranchavala kiranchavala left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, Tested manually

kubectl expose deploy nginx --type=LoadBalancer --port=80 --name=lb-proxy
kubectl get svc lb-proxy -w        # wait for EXTERNAL-IP
cmk list loadbalancerrules listall=true filter=id,name,protocol,publicport

Before fix

k describe svc lb-proxy
Name:                     lb-proxy
Namespace:                default
Labels:                   <none>
Annotations:              service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: true
Selector:                 app=nginx
Type:                     LoadBalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       10.104.141.145
IPs:                      10.104.141.145
LoadBalancer Ingress:     10.0.59.124 (VIP)
Port:                     <unset>  80/TCP
TargetPort:               80/TCP
NodePort:                 <unset>  31196/TCP
Endpoints:                192.168.239.5:80,192.168.239.6:80
Session Affinity:         None
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster
Events:
  Type     Reason                  Age                 From                Message
  ----     ------                  ----                ----                -------
  Normal   EnsuredLoadBalancer     2m30s               service-controller  Ensured load balancer
  Warning  SyncLoadBalancerFailed  21s (x3 over 37s)   service-controller  Error syncing load balancer: failed to ensure load balancer: error creating load balancer rule a778efb5964f04dea842e6cedce84e3b-tcp-proxy-80: CloudStack API error 537 (CSExceptionErrorCode: 9999): The range specified, 80-80, conflicts with rule FirewallRule {"id":34,"networkId":210,"purpose":"LoadBalancing","state":"Active","uuid":"4000f82d-afc4-46b3-ab92-e00cf5b9e7fe"} which has 80-80
  Normal   EnsuringLoadBalancer    1s (x5 over 2m40s)  service-controller  Ensuring load balancer

╰─ kubectl annotate svc lb-proxy   service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol="false" --overwrite
service/lb-proxy annotated


╰─ k describe svc lb-proxy
Name:                     lb-proxy
Namespace:                default
Labels:                   <none>
Annotations:              service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: false
Selector:                 app=nginx
Type:                     LoadBalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       10.104.141.145
IPs:                      10.104.141.145
LoadBalancer Ingress:     10.0.59.124 (VIP)
Port:                     <unset>  80/TCP
TargetPort:               80/TCP
NodePort:                 <unset>  31196/TCP
Endpoints:                192.168.239.5:80,192.168.239.6:80
Session Affinity:         None
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster
Events:
  Type     Reason                  Age                 From                Message
  ----     ------                  ----                ----                -------
  Normal   EnsuringLoadBalancer    5s (x7 over 3m25s)  service-controller  Ensuring load balancer
  Warning  SyncLoadBalancerFailed  5s (x5 over 82s)    service-controller  Error syncing load balancer: failed to ensure load balancer: error creating load balancer rule a778efb5964f04dea842e6cedce84e3b-tcp-proxy-80: CloudStack API error 537 (CSExceptionErrorCode: 9999): The range specified, 80-80, conflicts with rule FirewallRule {"id":34,"networkId":210,"purpose":"LoadBalancing","state":"Active","uuid":"4000f82d-afc4-46b3-ab92-e00cf5b9e7fe"} which has 80-80
  Normal   EnsuredLoadBalancer     4s (x2 over 3m15s)  service-controller  Ensured load balancer

After fix

k describe svc lb-proxy
Name:                     lb-proxy
Namespace:                default
Labels:                   <none>
Annotations:              <none>
Selector:                 app=nginx
Type:                     LoadBalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       10.111.255.47
IPs:                      10.111.255.47
Port:                     <unset>  80/TCP
TargetPort:               80/TCP
NodePort:                 <unset>  30499/TCP
Endpoints:                192.168.149.132:80,192.168.149.133:80
Session Affinity:         None
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster
Events:                   <none>
╭─ ~                                                                                                                                        ✔ ╱ kubernetes-admin@kubernetes 󱃾 ╱ 03:15:34 PM
╰─ kubectl annotate svc lb-proxy service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol="true" --overwrite
service/lb-proxy annotated
╭─ ~                                                                                                                                        ✔ ╱ kubernetes-admin@kubernetes 󱃾 ╱ 03:15:58 PM
╰─ k describe svc lb-proxy
Name:                     lb-proxy
Namespace:                default
Labels:                   <none>
Annotations:              service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: true
Selector:                 app=nginx
Type:                     LoadBalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       10.111.255.47
IPs:                      10.111.255.47
Port:                     <unset>  80/TCP
TargetPort:               80/TCP
NodePort:                 <unset>  30499/TCP
Endpoints:                192.168.149.132:80,192.168.149.133:80
Session Affinity:         None
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster
Events:
  Type    Reason                Age   From                Message
  ----    ------                ----  ----                -------
  Normal  EnsuringLoadBalancer  11s   service-controller  Ensuring load balancer
  Normal  UpdatedLoadBalancer   10s   service-controller  Updated load balancer with new hosts
╭─ ~                                                                                                                                        ✔ ╱ kubernetes-admin@kubernetes 󱃾 ╱ 03:16:02 PM
╰─ kubectl annotate svc lb-proxy   service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol="false" --overwrite
service/lb-proxy annotated
╭─ ~                                                                                                                                        ✔ ╱ kubernetes-admin@kubernetes 󱃾 ╱ 03:16:24 PM
╰─ k describe svc lb-proxy
Name:                     lb-proxy
Namespace:                default
Labels:                   <none>
Annotations:              service.beta.kubernetes.io/cloudstack-load-balancer-proxy-protocol: false
Selector:                 app=nginx
Type:                     LoadBalancer
IP Family Policy:         SingleStack
IP Families:              IPv4
IP:                       10.111.255.47
IPs:                      10.111.255.47
LoadBalancer Ingress:     10.0.59.124 (VIP)
Port:                     <unset>  80/TCP
TargetPort:               80/TCP
NodePort:                 <unset>  30499/TCP
Endpoints:                192.168.149.132:80,192.168.149.133:80
Session Affinity:         None
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster
Events:
  Type    Reason                Age                From                Message
  ----    ------                ----               ----                -------
  Normal  UpdatedLoadBalancer   35s                service-controller  Updated load balancer with new hosts
  Normal  EnsuredLoadBalancer   18s (x2 over 24s)  service-controller  Ensured load balancer
  Normal  EnsuringLoadBalancer  3s (x3 over 36s)   service-controller  Ensuring load balancer

Copilot AI review requested due to automatic review settings September 21, 2026 12:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 2 High severity · 1 Medium severity

Open (3)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (3)

Comment thread cloudstack_loadbalancer.go
Copilot AI review requested due to automatic review settings September 23, 2026 09:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)
Resolved since last review (1)

Comment thread cloudstack_loadbalancer.go Outdated
Comment on lines +697 to +705
lbRule, needsUpdate, err := lb.checkLoadBalancerRule(lb.findLoadBalancerRule(lbRuleName, port, protocol), lbRuleName, port, protocol, service, version)
if err != nil {
return nil, err
}

if lbRule != nil {
// Claim by the rule's actual name: after a protocol change it still carries the old one.
delete(lb.rules, lbRule.Name)
}
Comment thread cloudstack_loadbalancer.go Outdated
Comment on lines +442 to +444
// duplicate sweep from deleting the rule that clients and DNS are pointing at. The
// same address is the one the rules are reconciled towards when they span several,
// so which IP wins does not depend on the order CloudStack lists the rules in.
Comment thread cloudstack_loadbalancer.go Outdated

lb.ipAddr = lbRule.Publicip
lb.ipAddrID = lbRule.Publicipid
if lb.ipAddr == "" || lb.ipAddr != preferredIP {
Comment thread cloudstack_loadbalancer.go
Copilot AI review requested due to automatic review settings September 23, 2026 13:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +812 to +814
klog.Errorf("Skipping obsolete load balancer rule %v with invalid public port %v: %v", lbRule.Name, lbRule.Publicport, err)
continue
}
Comment on lines +824 to +827
if protocol == LoadBalancerProtocolInvalid && !blocksACreate {
klog.Errorf("Skipping obsolete load balancer rule %v with unknown protocol %v", lbRule.Name, lbRule.Protocol)
continue
}

cidrList, err := lb.getCIDRList(service)
if err != nil {
return ruleMissing, err
Comment on lines +691 to +695
func splitCIDRList(cidrList string) []string {
return strings.FieldsFunc(cidrList, func(r rune) bool {
return r == ',' || r == ' '
})
}
Comment thread test/e2e/vpc_test.go
Comment on lines +275 to +281
const port = "8085"
svc := f.CreateLBService(func(s *corev1.Service) {
s.Annotations = map[string]string{annotationProxyProtocol: "true"}
s.Spec.Ports = []corev1.ServicePort{
{Name: "http", Port: 8085, Protocol: corev1.ProtocolTCP},
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OLD] Changing from TCP to TCP Proxy doesn't work

4 participants