Skip to content

Commit 16e5513

Browse files
[azure] Implement excess-ip-release support
Signed-off-by: jaredledvina <jared.ledvina@datadoghq.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0e4992c commit 16e5513

13 files changed

Lines changed: 1030 additions & 10 deletions

File tree

Documentation/cmdref/cilium-operator-azure.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Documentation/cmdref/cilium-operator.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

operator/cmd/provider_azure_flags.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@ func (hook *azureFlagsHooks) RegisterProviderFlag(cmd *cobra.Command, vp *viper.
3434
flags.Bool(operatorOption.AzureUsePrimaryAddress, false, "Use Azure IP address from interface's primary IPConfigurations")
3535
option.BindEnvWithLegacyEnvFallback(vp, operatorOption.AzureUsePrimaryAddress, "AZURE_USE_PRIMARY_ADDRESS")
3636

37+
flags.Bool(operatorOption.AzureReleaseExcessIPs, false, "Enable releasing excess free IP addresses from Azure network interfaces.")
38+
option.BindEnv(vp, operatorOption.AzureReleaseExcessIPs)
39+
3740
vp.BindPFlags(flags)
3841
}

operator/option/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ const (
148148
// primary IPConfiguration
149149
AzureUsePrimaryAddress = "azure-use-primary-address"
150150

151+
// AzureReleaseExcessIPs allows releasing excess free IP addresses from
152+
// Azure network interfaces. Enabling this option reduces waste of IP
153+
// addresses but may increase the number of API calls to Azure.
154+
AzureReleaseExcessIPs = "azure-release-excess-ips"
155+
151156
// LeaderElectionLeaseDuration is the duration that non-leader candidates will wait to
152157
// force acquire leadership
153158
LeaderElectionLeaseDuration = "leader-election-lease-duration"
@@ -349,6 +354,10 @@ type OperatorConfig struct {
349354
// primary IPConfiguration
350355
AzureUsePrimaryAddress bool
351356

357+
// AzureReleaseExcessIPs allows releasing excess free IP addresses from
358+
// Azure network interfaces.
359+
AzureReleaseExcessIPs bool
360+
352361
// AlibabaCloud options
353362

354363
// AlibabaCloudVPCID allow user to specific vpc
@@ -465,6 +474,7 @@ func (c *OperatorConfig) Populate(logger *slog.Logger, vp *viper.Viper) {
465474
c.AzureSubscriptionID = vp.GetString(AzureSubscriptionID)
466475
c.AzureResourceGroup = vp.GetString(AzureResourceGroup)
467476
c.AzureUsePrimaryAddress = vp.GetBool(AzureUsePrimaryAddress)
477+
c.AzureReleaseExcessIPs = vp.GetBool(AzureReleaseExcessIPs)
468478
c.AzureUserAssignedIdentityID = vp.GetString(AzureUserAssignedIdentityID)
469479

470480
// AlibabaCloud options

pkg/azure/api/api.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,14 @@ func parseInterface(iface *armnetwork.Interface, subnets ipamTypes.SubnetMap, us
342342
State: strings.ToLower(string(*ip.Properties.ProvisioningState)),
343343
}
344344

345+
if ip.Name != nil {
346+
addr.SetIPConfigName(*ip.Name)
347+
}
348+
349+
if ip.Properties.Primary != nil {
350+
addr.SetPrimary(*ip.Properties.Primary)
351+
}
352+
345353
if ip.Properties.Subnet != nil {
346354
addr.Subnet = *ip.Properties.Subnet.ID
347355
if subnet, ok := subnets[addr.Subnet]; ok {
@@ -837,6 +845,216 @@ func (c *Client) AssignPrivateIpAddressesVM(ctx context.Context, subnetID, inter
837845
return nil
838846
}
839847

848+
// PrimaryReleaseError is returned by Unassign* when the requested release set
849+
// would drop one or more primary IPConfigurations from a NIC. Azure ARM
850+
// rejects such updates with an opaque NetworkingInternalOperationError, so
851+
// we detect the case pre-flight and refuse to issue the update.
852+
//
853+
// Returning this error keeps the IPAM framework from marking the IPs as
854+
// released in CiliumNode.Status.IPAM.ReleaseIPs, leaving the CRD in sync
855+
// with the live NIC state.
856+
type PrimaryReleaseError struct {
857+
// InterfaceName is the NIC the primary IPConfiguration belongs to.
858+
InterfaceName string
859+
// Items is either the IP addresses (VM path) or IPConfiguration names
860+
// (VMSS path) of the primaries that were requested for release.
861+
Items []string
862+
}
863+
864+
func (e *PrimaryReleaseError) Error() string {
865+
return fmt.Sprintf("interface %s: refusing to release primary IPConfiguration(s) %v", e.InterfaceName, e.Items)
866+
}
867+
868+
// dropMatchingIPConfigsVM partitions ipConfigs into those to keep and those
869+
// to drop based on releaseSet (set of IP addresses). Primary IPConfigurations
870+
// are always retained even if their IP appears in releaseSet; their IPs are
871+
// returned via primaryBlocked so the caller can refuse the update entirely.
872+
func dropMatchingIPConfigsVM(
873+
ipConfigs []*armnetwork.InterfaceIPConfiguration,
874+
releaseSet map[string]struct{},
875+
) (kept []*armnetwork.InterfaceIPConfiguration, dropped int, primaryBlocked []string) {
876+
kept = make([]*armnetwork.InterfaceIPConfiguration, 0, len(ipConfigs))
877+
for _, c := range ipConfigs {
878+
if c == nil || c.Properties == nil || c.Properties.PrivateIPAddress == nil {
879+
kept = append(kept, c)
880+
continue
881+
}
882+
ip := *c.Properties.PrivateIPAddress
883+
_, requested := releaseSet[ip]
884+
isPrimary := c.Properties.Primary != nil && *c.Properties.Primary
885+
switch {
886+
case requested && isPrimary:
887+
primaryBlocked = append(primaryBlocked, ip)
888+
kept = append(kept, c)
889+
case requested:
890+
dropped++
891+
default:
892+
kept = append(kept, c)
893+
}
894+
}
895+
return
896+
}
897+
898+
// dropMatchingIPConfigsVMSS partitions ipConfigs into those to keep and those
899+
// to drop based on releaseNames (set of IPConfiguration resource names). The
900+
// VMSS compute model only carries names and the Primary flag — IPs live in
901+
// the network model — so the caller passes names. Primary IPConfigurations
902+
// are always retained.
903+
func dropMatchingIPConfigsVMSS(
904+
ipConfigs []*armcompute.VirtualMachineScaleSetIPConfiguration,
905+
releaseNames map[string]struct{},
906+
) (kept []*armcompute.VirtualMachineScaleSetIPConfiguration, dropped int, primaryBlocked []string) {
907+
kept = make([]*armcompute.VirtualMachineScaleSetIPConfiguration, 0, len(ipConfigs))
908+
for _, c := range ipConfigs {
909+
if c == nil || c.Name == nil {
910+
kept = append(kept, c)
911+
continue
912+
}
913+
name := *c.Name
914+
_, requested := releaseNames[name]
915+
isPrimary := c.Properties != nil && c.Properties.Primary != nil && *c.Properties.Primary
916+
switch {
917+
case requested && isPrimary:
918+
primaryBlocked = append(primaryBlocked, name)
919+
kept = append(kept, c)
920+
case requested:
921+
dropped++
922+
default:
923+
kept = append(kept, c)
924+
}
925+
}
926+
return
927+
}
928+
929+
// UnassignPrivateIpAddressesVM unassigns the given private IP addresses from
930+
// the named NIC of a standalone VM.
931+
//
932+
// The Azure network model carries privateIPAddress on each IPConfiguration,
933+
// so matching by IP is straightforward. If any requested IP backs a primary
934+
// IPConfiguration the function returns *PrimaryReleaseError without issuing
935+
// the update.
936+
func (c *Client) UnassignPrivateIpAddressesVM(ctx context.Context, interfaceName string, addresses []string) error {
937+
if len(addresses) == 0 {
938+
return nil
939+
}
940+
941+
c.limiter.Limit(ctx, interfacesGet)
942+
sinceStart := spanstat.Start()
943+
944+
iface, err := c.interfaces.Get(ctx, c.resourceGroup, interfaceName, nil)
945+
c.metricsAPI.ObserveAPICall(interfacesGet, deriveStatus(err), sinceStart.Seconds())
946+
if err != nil {
947+
return fmt.Errorf("failed to get standalone instance's interface %s: %w", interfaceName, err)
948+
}
949+
950+
releaseSet := make(map[string]struct{}, len(addresses))
951+
for _, ip := range addresses {
952+
releaseSet[ip] = struct{}{}
953+
}
954+
955+
kept, dropped, primaryBlocked := dropMatchingIPConfigsVM(iface.Properties.IPConfigurations, releaseSet)
956+
if len(primaryBlocked) > 0 {
957+
return &PrimaryReleaseError{InterfaceName: interfaceName, Items: primaryBlocked}
958+
}
959+
if dropped == 0 {
960+
return nil
961+
}
962+
iface.Properties.IPConfigurations = kept
963+
964+
c.limiter.Limit(ctx, interfacesCreateOrUpdate)
965+
sinceStart = spanstat.Start()
966+
967+
poller, err := c.interfaces.BeginCreateOrUpdate(ctx, c.resourceGroup, interfaceName, iface.Interface, nil)
968+
defer func() {
969+
c.metricsAPI.ObserveAPICall(interfacesCreateOrUpdate, deriveStatus(err), sinceStart.Seconds())
970+
}()
971+
if err != nil {
972+
return fmt.Errorf("unable to update interface %s: %w", interfaceName, err)
973+
}
974+
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
975+
return fmt.Errorf("error while waiting for interface CreateOrUpdate to complete for %s: %w", interfaceName, err)
976+
}
977+
978+
return nil
979+
}
980+
981+
// UnassignPrivateIpAddressesVMSS unassigns the IPConfigurations identified by
982+
// ipConfigNames from the named NIC of a VMSS instance.
983+
//
984+
// The Azure compute model exposes IPConfiguration name and Primary but not
985+
// privateIPAddress, so the caller must translate IPs to IPConfiguration names
986+
// using the in-memory mapping populated by parseInterface. If any requested
987+
// name backs a primary IPConfiguration the function returns
988+
// *PrimaryReleaseError without issuing the update.
989+
func (c *Client) UnassignPrivateIpAddressesVMSS(ctx context.Context, instanceID, vmssName, interfaceName string, ipConfigNames []string) error {
990+
if len(ipConfigNames) == 0 {
991+
return nil
992+
}
993+
994+
vmssGetOptions := &armcompute.VirtualMachineScaleSetVMsClientGetOptions{
995+
Expand: to.Ptr(armcompute.InstanceViewTypesInstanceView),
996+
}
997+
998+
c.limiter.Limit(ctx, virtualMachineScaleSetVMsGet)
999+
sinceStart := spanstat.Start()
1000+
1001+
result, err := c.virtualMachineScaleSetVMs.Get(ctx, c.resourceGroup, vmssName, instanceID, vmssGetOptions)
1002+
c.metricsAPI.ObserveAPICall(virtualMachineScaleSetVMsGet, deriveStatus(err), sinceStart.Seconds())
1003+
if err != nil {
1004+
return fmt.Errorf("failed to get VM %s from VMSS %s: %w", instanceID, vmssName, err)
1005+
}
1006+
1007+
var netIfConfig *armcompute.VirtualMachineScaleSetNetworkConfiguration
1008+
if result.Properties.NetworkProfileConfiguration != nil {
1009+
for _, nic := range result.Properties.NetworkProfileConfiguration.NetworkInterfaceConfigurations {
1010+
if nic.Name != nil && *nic.Name == interfaceName {
1011+
netIfConfig = nic
1012+
break
1013+
}
1014+
}
1015+
}
1016+
if netIfConfig == nil {
1017+
return fmt.Errorf("interface %s does not exist in VM %s", interfaceName, instanceID)
1018+
}
1019+
1020+
releaseNames := make(map[string]struct{}, len(ipConfigNames))
1021+
for _, name := range ipConfigNames {
1022+
releaseNames[name] = struct{}{}
1023+
}
1024+
1025+
kept, dropped, primaryBlocked := dropMatchingIPConfigsVMSS(netIfConfig.Properties.IPConfigurations, releaseNames)
1026+
if len(primaryBlocked) > 0 {
1027+
return &PrimaryReleaseError{InterfaceName: interfaceName, Items: primaryBlocked}
1028+
}
1029+
if dropped == 0 {
1030+
return nil
1031+
}
1032+
netIfConfig.Properties.IPConfigurations = kept
1033+
1034+
// Unset imageReference for the same reason as AssignPrivateIpAddressesVMSS:
1035+
// preserves a possibly Azure-Compute-Gallery image reference on update.
1036+
// See https://github.com/Azure/AKS/issues/1819.
1037+
if result.Properties.StorageProfile != nil {
1038+
result.Properties.StorageProfile.ImageReference = nil
1039+
}
1040+
1041+
c.limiter.Limit(ctx, virtualMachineScaleSetVMsUpdate)
1042+
sinceStart = spanstat.Start()
1043+
1044+
poller, err := c.virtualMachineScaleSetVMs.BeginUpdate(ctx, c.resourceGroup, vmssName, instanceID, result.VirtualMachineScaleSetVM, nil)
1045+
defer func() {
1046+
c.metricsAPI.ObserveAPICall(virtualMachineScaleSetVMsUpdate, deriveStatus(err), sinceStart.Seconds())
1047+
}()
1048+
if err != nil {
1049+
return fmt.Errorf("unable to update virtualMachineScaleSetVMs: %w", err)
1050+
}
1051+
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
1052+
return fmt.Errorf("error while waiting for virtualMachineScaleSetVMs Update to complete: %w", err)
1053+
}
1054+
1055+
return nil
1056+
}
1057+
8401058
// AssignPublicIPAddressesVMSS assigns a public IP to a VMSS instance.
8411059
// The public IP is allocated from a Public IP Prefix matching publicIpTags
8421060
func (c *Client) AssignPublicIPAddressesVMSS(ctx context.Context, instanceID, vmssName string, publicIpTags ipamTypes.Tags) (string, error) {

0 commit comments

Comments
 (0)