@@ -142,14 +142,26 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context, clusterName string, s
142142 cs .eventRecorder .Event (service , corev1 .EventTypeNormal , "CreatedLoadBalancer" , msg )
143143 klog .Info (msg )
144144 } else if service .Spec .LoadBalancerIP != "" && service .Spec .LoadBalancerIP != lb .ipAddr {
145- // LoadBalancerIP was specified and it's different from the current IP
145+ // LoadBalancerIP was specified and it's different from the current IP.
146+ // Validate the target IP exists before tearing down the old config to avoid
147+ // leaving the service in a broken state if the new IP is invalid.
148+ if err := lb .validatePublicIPAvailable (service .Spec .LoadBalancerIP ); err != nil {
149+ return nil , fmt .Errorf ("cannot switch load balancer to IP %s: %w" , service .Spec .LoadBalancerIP , err )
150+ }
151+
146152 // Release the old IP first
147153 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 )
148154
149155 // Best-effort cleanup of existing rules bound to the current IP to avoid stale deletes / name conflicts.
150156 for _ , oldRule := range lb .rules {
151157 proto := ProtocolFromLoadBalancer (oldRule .Protocol )
158+ if proto == LoadBalancerProtocolInvalid {
159+ klog .Warningf ("Skipping firewall cleanup for rule %s: unrecognized protocol %q" , oldRule .Name , oldRule .Protocol )
160+ }
152161 port64 , pErr := strconv .ParseInt (oldRule .Publicport , 10 , 32 )
162+ if pErr != nil {
163+ klog .Warningf ("Skipping firewall cleanup for rule %s: cannot parse port %q: %v" , oldRule .Name , oldRule .Publicport , pErr )
164+ }
153165 if proto != LoadBalancerProtocolInvalid && pErr == nil {
154166 if _ , fwErr := lb .deleteFirewallRule (oldRule .Publicipid , int (port64 ), proto ); fwErr != nil {
155167 klog .V (4 ).Infof ("Ignoring firewall rule delete error for %s: %v" , oldRule .Name , fwErr )
@@ -240,10 +252,10 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context, clusterName string, s
240252 network , count , err := lb .Network .GetNetworkByID (lb .networkID , cloudstack .WithProject (lb .projectID ))
241253 if err != nil {
242254 if count == 0 {
243- return nil , err
255+ return nil , fmt . Errorf ( "could not find network with ID %s: %w" , lb . networkID , err )
244256 }
245257
246- return nil , err
258+ return nil , fmt . Errorf ( "failed to get network with ID %s: %w" , lb . networkID , err )
247259 }
248260
249261 lbSourceRanges , err := getLoadBalancerSourceRanges (service )
@@ -636,7 +648,7 @@ func (cs *CSCloud) listAllVirtualMachines() ([]*cloudstack.VirtualMachine, error
636648
637649 l , err := cs .client .VirtualMachine .ListVirtualMachines (p )
638650 if err != nil {
639- return nil , err
651+ return nil , fmt . Errorf ( "failed to list virtual machines: %w" , err )
640652 }
641653
642654 allVMs = append (allVMs , l .VirtualMachines ... )
@@ -665,6 +677,31 @@ func (lb *loadBalancer) getLoadBalancerIP(loadBalancerIP string) error {
665677 return lb .associatePublicIPAddress ()
666678}
667679
680+ // validatePublicIPAvailable checks that the given IP address exists in CloudStack
681+ // without modifying any load balancer state. Used as a pre-flight check before
682+ // tearing down an existing configuration.
683+ func (lb * loadBalancer ) validatePublicIPAvailable (ip string ) error {
684+ p := lb .Address .NewListPublicIpAddressesParams ()
685+ p .SetIpaddress (ip )
686+ p .SetAllocatedonly (false )
687+ p .SetListall (true )
688+
689+ if lb .projectID != "" {
690+ p .SetProjectid (lb .projectID )
691+ }
692+
693+ l , err := lb .Address .ListPublicIpAddresses (p )
694+ if err != nil {
695+ return fmt .Errorf ("error looking up IP address %v: %w" , ip , err )
696+ }
697+
698+ if l .Count != 1 {
699+ return fmt .Errorf ("IP address %v not found (got %d results)" , ip , l .Count )
700+ }
701+
702+ return nil
703+ }
704+
668705// getPublicIPAddressID retrieves the ID of the given IP, and sets the address and its ID.
669706func (lb * loadBalancer ) getPublicIPAddress (loadBalancerIP string ) error {
670707 klog .V (4 ).Infof ("Retrieve load balancer IP details: %v" , loadBalancerIP )
@@ -784,8 +821,11 @@ func (lb *loadBalancer) updateLoadBalancerRule(lbRuleName string, protocol LoadB
784821 p .SetProtocol (protocol .CSProtocol ())
785822
786823 _ , err := lb .LoadBalancer .UpdateLoadBalancerRule (p )
824+ if err != nil {
825+ return fmt .Errorf ("failed to update loadbalancer rule with ID %s: %w" , lbRule .Id , err )
826+ }
787827
788- return err
828+ return nil
789829}
790830
791831// createLoadBalancerRule creates a new load balancer rule and returns its ID.
@@ -933,6 +973,10 @@ func symmetricDifference(hostIDs []string, lbInstances []*cloudstack.VirtualMach
933973
934974 var remove []string //nolint:prealloc
935975 for _ , instance := range lbInstances {
976+ if instance == nil {
977+ continue
978+ }
979+
936980 if newIDs [instance .Id ] {
937981 delete (newIDs , instance .Id )
938982
@@ -1088,12 +1132,13 @@ func (lb *loadBalancer) updateFirewallRule(publicIPID string, publicPort int, pr
10881132 // delete all other rules that didn't match the CIDR list
10891133 // do this first to prevent CS rule conflict errors
10901134 klog .V (4 ).Infof ("Firewall rules to be deleted for %v: %v" , lb .ipAddr , rulesMapToString (filtered ))
1135+ var deleteErr error
10911136 for rule := range filtered {
10921137 p := lb .Firewall .NewDeleteFirewallRuleParams (rule .Id )
1093- _ , err = lb .Firewall .DeleteFirewallRule (p )
1094- if err != nil {
1138+ if _ , err = lb .Firewall .DeleteFirewallRule (p ); err != nil {
10951139 // report the error, but keep on deleting the other rules
10961140 klog .Errorf ("Error deleting old firewall rule %v: %v" , rule .Id , err )
1141+ deleteErr = err
10971142 }
10981143 }
10991144
@@ -1104,15 +1149,15 @@ func (lb *loadBalancer) updateFirewallRule(publicIPID string, publicPort int, pr
11041149 p .SetCidrlist (allowedCIDRs )
11051150 p .SetStartport (publicPort )
11061151 p .SetEndport (publicPort )
1107- _ , err = lb .Firewall .CreateFirewallRule (p )
1108- if err != nil {
1152+ if _ , err = lb .Firewall .CreateFirewallRule (p ); err != nil {
11091153 // return immediately if we can't create the new rule
11101154 return false , fmt .Errorf ("error creating new firewall rule for public IP %v, proto %v, port %v, allowed %v: %w" , publicIPID , protocol , publicPort , allowedCIDRs , err )
11111155 }
11121156 }
11131157
1114- // return true (because we changed something), but also the last error if deleting one old rule failed
1115- return true , err
1158+ changed := match == nil || len (filtered ) > 0
1159+
1160+ return changed , deleteErr
11161161}
11171162
11181163// deleteFirewallRule deletes the firewall rule associated with the ip:port:protocol combo
@@ -1139,18 +1184,20 @@ func (lb *loadBalancer) deleteFirewallRule(publicIPID string, publicPort int, pr
11391184 }
11401185
11411186 // delete all rules
1187+ var errs error
11421188 deleted := false
11431189 for _ , rule := range filtered {
11441190 p := lb .Firewall .NewDeleteFirewallRuleParams (rule .Id )
11451191 _ , err = lb .Firewall .DeleteFirewallRule (p )
11461192 if err != nil {
11471193 klog .Errorf ("Error deleting old firewall rule %v: %v" , rule .Id , err )
1194+ errs = errors .Join (errs , fmt .Errorf ("error deleting old firewall rule %v: %w" , rule .Id , err ))
11481195 } else {
11491196 deleted = true
11501197 }
11511198 }
11521199
1153- return deleted , err
1200+ return deleted , errs
11541201}
11551202
11561203// getLoadBalancerSourceRanges first tries to parse and verify loadBalancerSourceRanges field from a Service object.
0 commit comments