Skip to content

Commit 29e02a3

Browse files
authored
Merge pull request #115 from leaseweb/feat/deprecate_loadbalancerip
feat: Deprecate spec.LoadBalancerIP in favor of annotations
2 parents b88f30a + 0a8c733 commit 29e02a3

File tree

2 files changed

+698
-189
lines changed

2 files changed

+698
-189
lines changed

cloudstack/cloudstack_loadbalancer.go

Lines changed: 133 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,23 @@ const (
4949
// cluster. This is a workaround for https://github.com/kubernetes/kubernetes/issues/66607
5050
ServiceAnnotationLoadBalancerLoadbalancerHostname = "service.beta.kubernetes.io/cloudstack-load-balancer-hostname"
5151

52-
// ServiceAnnotationLoadBalancerAddress is a read-only annotation indicating the IP address assigned to the load balancer.
52+
// ServiceAnnotationLoadBalancerAddress is the annotation for the IP address assigned to the load balancer.
53+
// Users can set this annotation to request a specific IP address, replacing the deprecated spec.LoadBalancerIP field.
54+
// This annotation takes precedence; spec.LoadBalancerIP is only used as a fallback.
5355
ServiceAnnotationLoadBalancerAddress = "service.beta.kubernetes.io/cloudstack-load-balancer-address"
5456

57+
// ServiceAnnotationLoadBalancerKeepIP is a boolean annotation that, when set to "true",
58+
// prevents the public IP from being released when the service is deleted.
59+
ServiceAnnotationLoadBalancerKeepIP = "service.beta.kubernetes.io/cloudstack-load-balancer-keep-ip"
60+
61+
// ServiceAnnotationLoadBalancerID stores the CloudStack public IP UUID associated with the load balancer.
62+
// Used for efficient ID-based lookups instead of keyword-based searches.
63+
ServiceAnnotationLoadBalancerID = "service.beta.kubernetes.io/cloudstack-load-balancer-id"
64+
65+
// ServiceAnnotationLoadBalancerNetworkID stores the CloudStack network UUID associated with the load balancer.
66+
// Used together with ServiceAnnotationLoadBalancerID for scoped ID-based lookups.
67+
ServiceAnnotationLoadBalancerNetworkID = "service.beta.kubernetes.io/cloudstack-load-balancer-network-id"
68+
5569
// Used to construct the load balancer name.
5670
servicePrefix = "K8s_svc_"
5771
lbNameFormat = "%s%s_%s_%s"
@@ -77,7 +91,7 @@ func (cs *CSCloud) GetLoadBalancer(ctx context.Context, clusterName string, serv
7791
// Get the load balancer details and existing rules.
7892
name := cs.GetLoadBalancerName(ctx, clusterName, service)
7993
legacyName := cs.getLoadBalancerLegacyName(ctx, clusterName, service)
80-
lb, err := cs.getLoadBalancerByName(name, legacyName)
94+
lb, err := cs.getLoadBalancer(service, name, legacyName)
8195
if err != nil {
8296
return nil, false, err
8397
}
@@ -111,7 +125,7 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context, clusterName string, s
111125
// Get the load balancer details and existing rules.
112126
name := cs.GetLoadBalancerName(ctx, clusterName, service)
113127
legacyName := cs.getLoadBalancerLegacyName(ctx, clusterName, service)
114-
lb, err := cs.getLoadBalancerByName(name, legacyName)
128+
lb, err := cs.getLoadBalancer(service, name, legacyName)
115129
if err != nil {
116130
return nil, err
117131
}
@@ -132,117 +146,47 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context, clusterName string, s
132146
return nil, err
133147
}
134148

149+
// Resolve the desired IP: annotation takes precedence, spec.LoadBalancerIP is fallback.
150+
desiredIP := getLoadBalancerAddress(service)
151+
135152
if !lb.hasLoadBalancerIP() { //nolint:nestif
136153
// Before allocating a new IP, check the service annotation for a previously assigned IP.
137154
// This handles recovery from partial failures where the IP was allocated and annotated
138-
// but subsequent operations (rule creation, IP switch) failed.
155+
// but subsequent operations (rule creation) failed.
139156
annotatedIP := getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerAddress, "")
140157
if annotatedIP != "" {
141-
specIP := service.Spec.LoadBalancerIP
142-
if specIP == "" || specIP == annotatedIP {
143-
// Case 1: Auto-allocated IP recovery — the annotated IP is the one we want.
144-
found, lookupErr := lb.lookupPublicIPAddress(annotatedIP)
145-
if lookupErr != nil {
146-
klog.Warningf("Error looking up annotated IP %v for recovery: %v", annotatedIP, lookupErr)
147-
} else if found {
148-
klog.V(4).Infof("Recovered previously allocated IP %v from annotation", annotatedIP)
149-
}
150-
} else {
151-
// Case 2: IP switch retry — annotation holds old IP that failed to release.
152-
klog.V(4).Infof("Detected IP switch retry: annotation has %v, spec wants %v; attempting cleanup of old IP", annotatedIP, specIP)
153-
oldFound, lookupErr := lb.lookupPublicIPAddress(annotatedIP)
154-
if lookupErr != nil {
155-
klog.Warningf("Error looking up old annotated IP %v during IP switch cleanup: %v", annotatedIP, lookupErr)
156-
} else if oldFound {
157-
shouldRelease, shouldErr := cs.shouldReleaseLoadBalancerIP(lb, service)
158-
if shouldErr != nil {
159-
klog.Warningf("Error checking if old IP %v should be released: %v", annotatedIP, shouldErr)
160-
} else if shouldRelease {
161-
if releaseErr := lb.releaseLoadBalancerIP(); releaseErr != nil {
162-
klog.Warningf("Best-effort release of old IP %v failed: %v", annotatedIP, releaseErr)
163-
} else {
164-
klog.Infof("Released old IP %v during IP switch retry", annotatedIP)
165-
}
166-
}
167-
}
168-
// Reset so we fall through to allocate the new IP
169-
lb.ipAddr = ""
170-
lb.ipAddrID = ""
158+
found, lookupErr := lb.lookupPublicIPAddress(annotatedIP)
159+
if lookupErr != nil {
160+
klog.Warningf("Error looking up annotated IP %v for recovery: %v", annotatedIP, lookupErr)
161+
} else if found {
162+
klog.V(4).Infof("Recovered previously allocated IP %v from annotation", annotatedIP)
171163
}
172164
}
173165

174166
if !lb.hasLoadBalancerIP() {
175167
// Create or retrieve the load balancer IP.
176-
if err := lb.getLoadBalancerIP(service.Spec.LoadBalancerIP); err != nil {
168+
if err := lb.getLoadBalancerIP(desiredIP); err != nil {
177169
return nil, err
178170
}
179171
}
180172

181173
msg := fmt.Sprintf("Created new load balancer for service %s with algorithm '%s' and IP address %s", serviceName, lb.algorithm, lb.ipAddr)
182174
cs.eventRecorder.Event(service, corev1.EventTypeNormal, "CreatedLoadBalancer", msg)
183175
klog.Info(msg)
184-
} else if service.Spec.LoadBalancerIP != "" && service.Spec.LoadBalancerIP != lb.ipAddr {
185-
// LoadBalancerIP was specified and it's different from the current IP.
186-
// Validate the target IP exists before tearing down the old config to avoid
187-
// leaving the service in a broken state if the new IP is invalid.
188-
if err := lb.validatePublicIPAvailable(service.Spec.LoadBalancerIP); err != nil {
189-
return nil, fmt.Errorf("cannot switch load balancer to IP %s: %w", service.Spec.LoadBalancerIP, err)
190-
}
191-
192-
// Release the old IP first
193-
klog.V(4).Infof("Deleting firewall rules for old ip and releasing old load balancer IP %v, switching to specified IP %v", lb.ipAddr, service.Spec.LoadBalancerIP)
194-
195-
// Best-effort cleanup of existing rules bound to the current IP to avoid stale deletes / name conflicts.
196-
for _, oldRule := range lb.rules {
197-
proto := ProtocolFromLoadBalancer(oldRule.Protocol)
198-
if proto == LoadBalancerProtocolInvalid {
199-
klog.Warningf("Skipping firewall cleanup for rule %s: unrecognized protocol %q", oldRule.Name, oldRule.Protocol)
200-
}
201-
port64, pErr := strconv.ParseInt(oldRule.Publicport, 10, 32)
202-
if pErr != nil {
203-
klog.Warningf("Skipping firewall cleanup for rule %s: cannot parse port %q: %v", oldRule.Name, oldRule.Publicport, pErr)
204-
}
205-
if proto != LoadBalancerProtocolInvalid && pErr == nil {
206-
if _, fwErr := lb.deleteFirewallRule(oldRule.Publicipid, int(port64), proto); fwErr != nil {
207-
klog.V(4).Infof("Ignoring firewall rule delete error for %s: %v", oldRule.Name, fwErr)
208-
}
209-
}
210-
211-
if delErr := lb.deleteLoadBalancerRule(oldRule); delErr != nil {
212-
// CloudStack sometimes reports deletes as "invalid value" when the entity is already gone.
213-
if strings.Contains(delErr.Error(), "does not exist") || strings.Contains(delErr.Error(), "Invalid parameter id value") {
214-
klog.V(4).Infof("Load balancer rule %s already removed, continuing: %v", oldRule.Name, delErr)
215-
216-
continue
217-
}
218-
219-
return nil, delErr
220-
}
221-
}
222-
223-
// Prevent any further cleanup from trying to delete stale IDs.
224-
lb.rules = make(map[string]*cloudstack.LoadBalancerRule)
225-
226-
if err := lb.releaseLoadBalancerIP(); err != nil {
227-
klog.Errorf("attempt to release old load balancer IP failed: %s", err.Error())
228-
229-
return nil, fmt.Errorf("failed to release old load balancer IP: %w", err)
230-
}
231-
232-
if err := lb.getLoadBalancerIP(service.Spec.LoadBalancerIP); err != nil {
233-
klog.Errorf("failed to allocate specified IP %v: %v", service.Spec.LoadBalancerIP, err)
234-
235-
return nil, fmt.Errorf("failed to allocate specified load balancer IP: %w", err)
236-
}
237-
238-
msg := fmt.Sprintf("Switched load balancer for service %s to specified IP address %s", serviceName, lb.ipAddr)
239-
cs.eventRecorder.Event(service, corev1.EventTypeNormal, "UpdatedLoadBalancer", msg)
176+
} else if desiredIP != "" && desiredIP != lb.ipAddr {
177+
// IP reassignment on an active load balancer is not supported.
178+
// Users must delete and recreate the service to change the IP.
179+
msg := fmt.Sprintf("Load balancer IP change from %s to %s is not supported; delete and recreate the service to use a different IP", lb.ipAddr, desiredIP)
180+
cs.eventRecorder.Event(service, corev1.EventTypeWarning, "IPChangeNotSupported", msg)
181+
klog.Warning(msg)
240182
}
241183

242184
klog.V(4).Infof("Load balancer %v is associated with IP %v", lb.name, lb.ipAddr)
243185

244-
// Set the load balancer IP address annotation on the Service
186+
// Set the load balancer annotations on the Service
245187
setServiceAnnotation(service, ServiceAnnotationLoadBalancerAddress, lb.ipAddr)
188+
setServiceAnnotation(service, ServiceAnnotationLoadBalancerID, lb.ipAddrID)
189+
setServiceAnnotation(service, ServiceAnnotationLoadBalancerNetworkID, lb.networkID)
246190

247191
for _, port := range service.Spec.Ports {
248192
// Construct the protocol name first, we need it a few times
@@ -347,7 +291,7 @@ func (cs *CSCloud) UpdateLoadBalancer(ctx context.Context, clusterName string, s
347291
// Get the load balancer details and existing rules.
348292
name := cs.GetLoadBalancerName(ctx, clusterName, service)
349293
legacyName := cs.getLoadBalancerLegacyName(ctx, clusterName, service)
350-
lb, err := cs.getLoadBalancerByName(name, legacyName)
294+
lb, err := cs.getLoadBalancer(service, name, legacyName)
351295
if err != nil {
352296
return err
353297
}
@@ -386,7 +330,7 @@ func (cs *CSCloud) EnsureLoadBalancerDeleted(ctx context.Context, clusterName st
386330
// Get the load balancer details and existing rules.
387331
name := cs.GetLoadBalancerName(ctx, clusterName, service)
388332
legacyName := cs.getLoadBalancerLegacyName(ctx, clusterName, service)
389-
lb, err := cs.getLoadBalancerByName(name, legacyName)
333+
lb, err := cs.getLoadBalancer(service, name, legacyName)
390334
if err != nil {
391335
return err
392336
}
@@ -492,10 +436,10 @@ func (cs *CSCloud) EnsureLoadBalancerDeleted(ctx context.Context, clusterName st
492436

493437
// shouldReleaseLoadBalancerIP determines whether the public IP should be released.
494438
func (cs *CSCloud) shouldReleaseLoadBalancerIP(lb *loadBalancer, service *corev1.Service) (bool, error) {
495-
// If the IP was explicitly specified in the service spec, don't release it
496-
// The user is responsible for managing the lifecycle of user-provided IPs
497-
if service.Spec.LoadBalancerIP != "" && service.Spec.LoadBalancerIP == lb.ipAddr {
498-
klog.V(4).Infof("IP %v was explicitly specified in service spec, not releasing", lb.ipAddr)
439+
// If the keep-ip annotation is set to true, don't release the IP.
440+
// The user is responsible for managing the lifecycle of kept IPs.
441+
if getBoolFromServiceAnnotation(service, ServiceAnnotationLoadBalancerKeepIP, false) {
442+
klog.V(4).Infof("IP %v has keep-ip annotation set, not releasing", lb.ipAddr)
499443

500444
return false, nil
501445
}
@@ -553,7 +497,7 @@ func (cs *CSCloud) releaseOrphanedIPIfNeeded(lb *loadBalancer, service *corev1.S
553497
}
554498

555499
if !shouldRelease {
556-
klog.V(4).Infof("Annotated IP %v should not be released (user-specified or has other rules)", annotatedIP)
500+
klog.V(4).Infof("Annotated IP %v should not be released (keep-ip set or has other rules)", annotatedIP)
557501

558502
return nil
559503
}
@@ -594,6 +538,28 @@ func filterRulesByPrefix(rules []*cloudstack.LoadBalancerRule, prefix string) []
594538
return filtered
595539
}
596540

541+
// getLoadBalancer tries to find the load balancer using ID-based lookup first (if annotations
542+
// are present), then falls back to the keyword-based name lookup.
543+
func (cs *CSCloud) getLoadBalancer(service *corev1.Service, name, legacyName string) (*loadBalancer, error) {
544+
if ipAddrID := getLoadBalancerID(service); ipAddrID != "" {
545+
networkID := getLoadBalancerNetworkID(service)
546+
klog.V(4).Infof("Attempting ID-based load balancer lookup: ipAddrID=%v, networkID=%v", ipAddrID, networkID)
547+
548+
lb, err := cs.getLoadBalancerByID(name, ipAddrID, networkID)
549+
if err != nil {
550+
return nil, err
551+
}
552+
553+
if len(lb.rules) > 0 {
554+
return lb, nil
555+
}
556+
557+
klog.V(4).Infof("ID-based lookup returned no rules, falling back to name-based lookup")
558+
}
559+
560+
return cs.getLoadBalancerByName(name, legacyName)
561+
}
562+
597563
// getLoadBalancerByName retrieves the IP address and ID and all the existing rules it can find.
598564
func (cs *CSCloud) getLoadBalancerByName(name, legacyName string) (*loadBalancer, error) {
599565
lb := &loadBalancer{
@@ -654,6 +620,50 @@ func (cs *CSCloud) getLoadBalancerByName(name, legacyName string) (*loadBalancer
654620
return lb, nil
655621
}
656622

623+
// getLoadBalancerByID retrieves load balancer rules by public IP ID and network ID.
624+
// This is more reliable than keyword-based search as it uses exact ID matching.
625+
func (cs *CSCloud) getLoadBalancerByID(name, ipAddrID, networkID string) (*loadBalancer, error) {
626+
lb := &loadBalancer{
627+
CloudStackClient: cs.client,
628+
name: name,
629+
projectID: cs.projectID,
630+
rules: make(map[string]*cloudstack.LoadBalancerRule),
631+
}
632+
633+
p := cs.client.LoadBalancer.NewListLoadBalancerRulesParams()
634+
p.SetPublicipid(ipAddrID)
635+
p.SetListall(true)
636+
637+
if networkID != "" {
638+
p.SetNetworkid(networkID)
639+
}
640+
641+
if cs.projectID != "" {
642+
p.SetProjectid(cs.projectID)
643+
}
644+
645+
l, err := cs.client.LoadBalancer.ListLoadBalancerRules(p)
646+
if err != nil {
647+
return nil, fmt.Errorf("error retrieving load balancer rules by IP ID %v: %w", ipAddrID, err)
648+
}
649+
650+
for _, lbRule := range l.LoadBalancerRules {
651+
lb.rules[lbRule.Name] = lbRule
652+
653+
if lb.ipAddr != "" && lb.ipAddr != lbRule.Publicip {
654+
klog.Warningf("Load balancer %v has rules associated with different IP's: %v, %v", lb.name, lb.ipAddr, lbRule.Publicip)
655+
}
656+
657+
lb.ipAddr = lbRule.Publicip
658+
lb.ipAddrID = lbRule.Publicipid
659+
lb.networkID = lbRule.Networkid
660+
}
661+
662+
klog.V(4).Infof("Load balancer %v (by ID %v) contains %d rule(s)", lb.name, ipAddrID, len(lb.rules))
663+
664+
return lb, nil
665+
}
666+
657667
// verifyHosts verifies if all hosts belong to the same network, and returns the host ID's and network ID.
658668
// During rolling upgrades some nodes may not yet have a corresponding VM in CloudStack, so we tolerate
659669
// partial matches: as long as at least one node can be resolved we return the matched set and log
@@ -782,31 +792,6 @@ func (lb *loadBalancer) getLoadBalancerIP(loadBalancerIP string) error {
782792
return lb.associatePublicIPAddress()
783793
}
784794

785-
// validatePublicIPAvailable checks that the given IP address exists in CloudStack
786-
// without modifying any load balancer state. Used as a pre-flight check before
787-
// tearing down an existing configuration.
788-
func (lb *loadBalancer) validatePublicIPAvailable(ip string) error {
789-
p := lb.Address.NewListPublicIpAddressesParams()
790-
p.SetIpaddress(ip)
791-
p.SetAllocatedonly(false)
792-
p.SetListall(true)
793-
794-
if lb.projectID != "" {
795-
p.SetProjectid(lb.projectID)
796-
}
797-
798-
l, err := lb.Address.ListPublicIpAddresses(p)
799-
if err != nil {
800-
return fmt.Errorf("error looking up IP address %v: %w", ip, err)
801-
}
802-
803-
if l.Count != 1 {
804-
return fmt.Errorf("IP address %v not found (got %d results)", ip, l.Count)
805-
}
806-
807-
return nil
808-
}
809-
810795
// lookupPublicIPAddress checks whether the given IP address is already allocated in CloudStack.
811796
// If found and allocated, it sets lb.ipAddr and lb.ipAddrID and returns (true, nil).
812797
// If not found or not allocated, it returns (false, nil) without modifying lb state.
@@ -1405,6 +1390,30 @@ func getBoolFromServiceAnnotation(service *corev1.Service, annotationKey string,
14051390
return defaultSetting
14061391
}
14071392

1393+
// getLoadBalancerAddress returns the desired load balancer IP address.
1394+
// It checks the ServiceAnnotationLoadBalancerAddress annotation first (preferred),
1395+
// then falls back to the deprecated spec.LoadBalancerIP field.
1396+
func getLoadBalancerAddress(service *corev1.Service) string {
1397+
if service == nil {
1398+
return ""
1399+
}
1400+
if addr := getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerAddress, ""); addr != "" {
1401+
return addr
1402+
}
1403+
1404+
return service.Spec.LoadBalancerIP //nolint:staticcheck // deprecated but kept as fallback
1405+
}
1406+
1407+
// getLoadBalancerID returns the stored load balancer public IP UUID from the service annotation.
1408+
func getLoadBalancerID(service *corev1.Service) string {
1409+
return getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerID, "")
1410+
}
1411+
1412+
// getLoadBalancerNetworkID returns the stored load balancer network UUID from the service annotation.
1413+
func getLoadBalancerNetworkID(service *corev1.Service) string {
1414+
return getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerNetworkID, "")
1415+
}
1416+
14081417
// setServiceAnnotation is used to create/set or update an annotation on the Service object.
14091418
func setServiceAnnotation(service *corev1.Service, key, value string) {
14101419
if service.ObjectMeta.Annotations == nil {

0 commit comments

Comments
 (0)