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