@@ -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.
494438func (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.
598564func (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.
14091418func setServiceAnnotation (service * corev1.Service , key , value string ) {
14101419 if service .ObjectMeta .Annotations == nil {
0 commit comments