@@ -17,6 +17,7 @@ import (
1717 operatorOption "github.com/cilium/cilium/operator/option"
1818 "github.com/cilium/cilium/pkg/defaults"
1919 metricsmock "github.com/cilium/cilium/pkg/ipam/metrics/mock"
20+ ipamOption "github.com/cilium/cilium/pkg/ipam/option"
2021 ipamStats "github.com/cilium/cilium/pkg/ipam/stats"
2122 ipamTypes "github.com/cilium/cilium/pkg/ipam/types"
2223 v2 "github.com/cilium/cilium/pkg/k8s/apis/cilium.io/v2"
@@ -588,6 +589,139 @@ func TestNodeManagerAbortRelease(t *testing.T) {
588589 require .Equal (t , 3 , node .Stats ().IPv4 .UsedIPs )
589590}
590591
592+ // TestNodeManagerAbortReleaseIPReassignment tests a scenario where:
593+ // 1. An IP is released and marked as released in status.ipam.release-ips
594+ // 2. The IP is removed from spec.ipam.pool
595+ // 3. Before being removed from status.ipam.release-ips, the same IP is reassigned to the pool (ie. AWS ENI in the case of AWS ENI IPAM)
596+ // 4. The IP is no longer considered excess, so the release handshake is aborted and the IP is deleted from status.ipam.release-ips
597+ func TestNodeManagerAbortReleaseIPReassignment (t * testing.T ) {
598+ operatorOption .Config .ExcessIPReleaseDelay = 2
599+ am := newAllocationImplementationMock ()
600+ require .NotNil (t , am )
601+ mngr , err := NewNodeManager (am , k8sapi , metricsmock .NewMockMetrics (), 10 , true , false )
602+ require .NoError (t , err )
603+ require .NotNil (t , mngr )
604+
605+ // Announce node, wait for IPs to become available
606+ cn := newCiliumNode ("node4" , 1 , 3 , 0 )
607+ mngr .Upsert (cn )
608+ require .NoError (t , testutils .WaitUntil (func () bool { return reachedAddressesNeeded (mngr , "node4" , 0 ) }, 5 * time .Second ))
609+
610+ node := mngr .Get ("node4" )
611+ require .NotNil (t , node )
612+ require .Equal (t , 3 , node .Stats ().IPv4 .AvailableIPs )
613+ require .Equal (t , 0 , node .Stats ().IPv4 .UsedIPs )
614+
615+ // Use 3 out of 4 IPs
616+ mngr .Upsert (updateCiliumNode (cn , 3 ))
617+ require .NoError (t , testutils .WaitUntil (func () bool { return reachedAddressesNeeded (mngr , "node4" , 0 ) }, 5 * time .Second ))
618+
619+ node = mngr .Get ("node4" )
620+ require .NotNil (t , node )
621+ require .Equal (t , 4 , node .Stats ().IPv4 .AvailableIPs )
622+ require .Equal (t , 3 , node .Stats ().IPv4 .UsedIPs )
623+
624+ // Release one IP
625+ mngr .Upsert (updateCiliumNode (node .resource , 2 ))
626+
627+ node = mngr .Get ("node4" )
628+ require .NotNil (t , node )
629+ require .Equal (t , 4 , node .Stats ().IPv4 .AvailableIPs )
630+ require .Equal (t , 2 , node .Stats ().IPv4 .UsedIPs )
631+
632+ node .instanceSync .Trigger ()
633+
634+ // This is the key IP that will be released and later reassigned
635+ var releasedIP string
636+
637+ // Wait for the IP to be marked as excess for longer than the ExcessIPReleaseDelay
638+ require .Eventually (t , func () bool {
639+ node .mutex .Lock ()
640+ defer node .mutex .Unlock ()
641+
642+ if len (node .ipv4Alloc .ipsMarkedForRelease ) == 0 {
643+ return false
644+ }
645+
646+ for _ , ts := range node .ipv4Alloc .ipsMarkedForRelease {
647+ if ts .Add (time .Duration (operatorOption .Config .ExcessIPReleaseDelay ) * time .Second ).Before (time .Now ()) {
648+ return true
649+ }
650+ }
651+
652+ return false
653+ }, 10 * time .Second , time .Second )
654+
655+ // Mark IP as excess
656+ node .instanceSync .Trigger ()
657+
658+ // Wait for maintenance action to identify the IP to release
659+ require .Eventually (t , func () bool {
660+ a , err := node .determineMaintenanceAction ()
661+ if err != nil || a == nil || a .release == nil || len (a .release .IPsToRelease ) == 0 {
662+ return false
663+ }
664+
665+ // Get the IP being released
666+ releasedIP = a .release .IPsToRelease [0 ]
667+ return releasedIP != ""
668+ }, 10 * time .Second , time .Second )
669+
670+ node .PopulateIPReleaseStatus (node .resource )
671+
672+ // Verify it's marked for release in the CiliumNode resource
673+ require .Contains (t , node .resource .Status .IPAM .ReleaseIPs , releasedIP )
674+ require .Equal (t , ipamOption .IPAMMarkForRelease , string (node .resource .Status .IPAM .ReleaseIPs [releasedIP ]))
675+
676+ // Fake acknowledge IP for release like agent would
677+ testipam .FakeAcknowledgeReleaseIps (node .resource )
678+
679+ // Resync one more time to process acknowledgements.
680+ node .instanceSync .Trigger ()
681+
682+ require .Eventually (t , func () bool {
683+ status , exists := node .resource .Status .IPAM .ReleaseIPs [releasedIP ]
684+ return exists && string (status ) == ipamOption .IPAMReadyForRelease
685+ }, 10 * time .Second , time .Second )
686+
687+ // Now simulate the operator releasing the IP and marking it as released
688+ node .mutex .Lock ()
689+ delete (node .resource .Spec .IPAM .Pool , releasedIP )
690+ node .resource .Status .IPAM .ReleaseIPs [releasedIP ] = ipamOption .IPAMReleased
691+ node .mutex .Unlock ()
692+ // Also mark it as released in the internal ipReleaseStatus map
693+ node .ipv4Alloc .ipReleaseStatus [releasedIP ] = ipamOption .IPAMReleased
694+
695+ // Normally at this point, the agent would see the IP is released and remove it from Status.IPAM.ReleaseIPs
696+ // But before that happens, simulate the IP being reassigned back to the pool
697+ if node .resource .Spec .IPAM .Pool == nil {
698+ node .resource .Spec .IPAM .Pool = ipamTypes.AllocationMap {}
699+ }
700+ node .resource .Spec .IPAM .Pool [releasedIP ] = ipamTypes.AllocationIP {Resource : "eni-test" }
701+ node .ops .AllocateIPs (context .Background (), & AllocationAction {
702+ IPv4 : IPAllocationAction {
703+ AvailableForAllocation : 1 ,
704+ },
705+ })
706+
707+ node .poolMaintainer .Trigger ()
708+ node .instanceSync .Trigger ()
709+
710+ require .Eventually (t , func () bool {
711+ node .PopulateIPReleaseStatus (node .resource )
712+ _ , inReleaseStatus := node .ipv4Alloc .ipReleaseStatus [releasedIP ]
713+ _ , inMarkedForRelease := node .ipv4Alloc .ipsMarkedForRelease [releasedIP ]
714+ _ , inReleaseIPs := node .resource .Status .IPAM .ReleaseIPs [releasedIP ]
715+
716+ return ! inReleaseStatus && ! inMarkedForRelease && ! inReleaseIPs
717+ }, 10 * time .Second , time .Second )
718+
719+ node = mngr .Get ("node4" )
720+ require .NotNil (t , node )
721+ require .Equal (t , 4 , node .Stats ().IPv4 .AvailableIPs )
722+ require .Equal (t , 2 , node .Stats ().IPv4 .UsedIPs )
723+ }
724+
591725type nodeState struct {
592726 cn * v2.CiliumNode
593727 name string
0 commit comments