@@ -4484,6 +4484,140 @@ func TestDeleteNode(t *testing.T) {
44844484 }
44854485}
44864486
4487+ // TestDeleteNode_SoftDeleteUpdatesCacheDeletedFlag verifies that when a node is
4488+ // soft-deleted (marked Deleted=true because it still has volume publications),
4489+ // the in-memory cache is updated with the Deleted flag.
4490+ func TestDeleteNode_SoftDeleteUpdatesCacheDeletedFlag (t * testing.T ) {
4491+ mockCtrl := gomock .NewController (t )
4492+ defer mockCtrl .Finish ()
4493+
4494+ mockStoreClient := mockpersistentstore .NewMockStoreClient (mockCtrl )
4495+
4496+ o := getOrchestrator (t , false )
4497+ o .storeClient = mockStoreClient
4498+
4499+ nodeName := "test-node-1"
4500+ volumeName := "test-vol-1"
4501+
4502+ // Set up a node.
4503+ o .nodes .Set (nodeName , & models.Node {
4504+ Name : nodeName ,
4505+ IPs : []string {"10.0.0.1" },
4506+ Deleted : false ,
4507+ })
4508+
4509+ // Set up a volume publication so DeleteNode takes the soft-delete path.
4510+ err := o .volumePublications .Set (volumeName , nodeName , & models.VolumePublication {
4511+ NodeName : nodeName ,
4512+ VolumeName : volumeName ,
4513+ })
4514+ assert .NoError (t , err )
4515+
4516+ // Mock: AddOrUpdateNode is called to persist the soft-delete.
4517+ mockStoreClient .EXPECT ().AddOrUpdateNode (gomock .Any (), gomock .Any ()).Return (nil )
4518+
4519+ // Act: Delete the node while it still has publications -> soft-delete.
4520+ err = o .DeleteNode (ctx (), nodeName )
4521+ assert .NoError (t , err )
4522+
4523+ // Assert: Node should still be in cache (not hard-deleted).
4524+ node := o .nodes .Get (nodeName )
4525+ assert .NotNil (t , node , "Node should still be in cache after soft-delete" )
4526+
4527+ // Assert: The Deleted flag in the cache MUST be true.
4528+ // NodeCache.Get() returns a deep copy.
4529+ // DeleteNode was modifying the copy and persisting to store, but never
4530+ // calling o.nodes.Set() to update the cache. So the cache still had Deleted=false.
4531+ assert .True (t , node .Deleted ,
4532+ "Node in cache should have Deleted=true after soft-delete; " +
4533+ "if this fails, the cache was not updated" )
4534+ }
4535+
4536+ // TestDeleteNode_SoftDeletedNodeCleanedUpAfterLastUnpublish verifies the full
4537+ // end-to-end flow: when a node is soft-deleted and then all its volumes are
4538+ // unpublished, the tridentNode CR (and cache entry) should be cleaned up.
4539+ func TestDeleteNode_SoftDeletedNodeCleanedUpAfterLastUnpublish (t * testing.T ) {
4540+ mockCtrl := gomock .NewController (t )
4541+ defer mockCtrl .Finish ()
4542+
4543+ backendUUID := "backend-uuid-1"
4544+ nodeName := "test-node-1"
4545+ volumeName := "test-vol-1"
4546+
4547+ mockBackend := mockstorage .NewMockBackend (mockCtrl )
4548+ mockBackend .EXPECT ().BackendUUID ().Return (backendUUID ).AnyTimes ()
4549+ mockStoreClient := mockpersistentstore .NewMockStoreClient (mockCtrl )
4550+
4551+ o := getOrchestrator (t , false )
4552+ o .storeClient = mockStoreClient
4553+
4554+ // Set up a node.
4555+ o .nodes .Set (nodeName , & models.Node {
4556+ Name : nodeName ,
4557+ IPs : []string {"10.0.0.1" },
4558+ Deleted : false ,
4559+ })
4560+
4561+ // Set up a volume.
4562+ o .volumes = map [string ]* storage.Volume {
4563+ volumeName : {
4564+ BackendUUID : backendUUID ,
4565+ Config : & storage.VolumeConfig {Name : volumeName },
4566+ },
4567+ }
4568+
4569+ // Set up a single volume publication.
4570+ err := o .volumePublications .Set (volumeName , nodeName , & models.VolumePublication {
4571+ NodeName : nodeName ,
4572+ VolumeName : volumeName ,
4573+ })
4574+ assert .NoError (t , err )
4575+
4576+ // --- Step 1: Delete the node (soft-delete because publication exists) ---
4577+ // Note: Don't add the mock backend to o.backends yet, because DeleteNode calls
4578+ // updateMetrics() which would call backend methods we don't want to mock here.
4579+ mockStoreClient .EXPECT ().AddOrUpdateNode (gomock .Any (), gomock .Any ()).Return (nil )
4580+
4581+ err = o .DeleteNode (ctx (), nodeName )
4582+ assert .NoError (t , err )
4583+
4584+ // Node should still exist in cache (soft-deleted).
4585+ node := o .nodes .Get (nodeName )
4586+ assert .NotNil (t , node , "Node should still be in cache after soft-delete" )
4587+
4588+ // --- Step 2: Unpublish the last volume from the soft-deleted node ---
4589+ // Now add the backend (needed for the unpublish flow).
4590+ o .backends [backendUUID ] = mockBackend
4591+
4592+ // Mock expectations for the unpublish flow.
4593+ mockBackend .EXPECT ().UnpublishVolume (gomock .Any (), gomock .Any (), gomock .Any ()).Return (nil )
4594+ mockStoreClient .EXPECT ().UpdateVolume (gomock .Any (), gomock .Any ()).Return (nil )
4595+ mockStoreClient .EXPECT ().DeleteVolumePublication (gomock .Any (), gomock .Any ()).Return (nil )
4596+
4597+ // These are called by deleteNode() when the soft-deleted node is finally cleaned up.
4598+ // We require exactly one call each to verify the persistent-store cleanup and
4599+ // backend invalidation actually occur on the success path.
4600+ nodeDeleted := false
4601+ mockStoreClient .EXPECT ().DeleteNode (gomock .Any (), gomock .Any ()).
4602+ DoAndReturn (func (_ context.Context , _ * models.Node ) error {
4603+ nodeDeleted = true
4604+ return nil
4605+ }).Times (1 )
4606+ mockBackend .EXPECT ().InvalidateNodeAccess ().Times (1 )
4607+
4608+ err = o .unpublishVolume (coreCtx , volumeName , nodeName )
4609+ assert .NoError (t , err )
4610+
4611+ // Assert: deleteNode was called to remove the soft-deleted node from the persistent store.
4612+ assert .True (t , nodeDeleted ,
4613+ "storeClient.DeleteNode should have been called to clean up the soft-deleted node" )
4614+
4615+ // Assert: The node should now be fully removed from cache.
4616+ node = o .nodes .Get (nodeName )
4617+ assert .Nil (t , node ,
4618+ "Node should be removed from cache after last volume is unpublished from soft-deleted node" )
4619+ }
4620+
44874621func TestSnapshotVolumes (t * testing.T ) {
44884622 mockPools := tu .GetFakePools ()
44894623 orchestrator := getOrchestrator (t , false )
0 commit comments