@@ -608,6 +608,120 @@ func TestAutoplaceSkipsEvictedNodes(t *testing.T) {
608608 }
609609}
610610
611+ // TestAutoplaceSharedLUNAntiAffinity: two pools share the same backing
612+ // LUN (SharedSpaceID="exos-lun-42"); a 2-replica autoplace must never
613+ // land both replicas on those pools, even though they live on
614+ // distinct nodes. Real-world rationale: at the physical layer the
615+ // LUN is the same disk, so both replicas would sit on the same
616+ // failure domain — defeating the redundancy a 2-replica RD promises.
617+ func TestAutoplaceSharedLUNAntiAffinity (t * testing.T ) {
618+ st := store .NewInMemory ()
619+ ctx := t .Context ()
620+
621+ if err := st .ResourceDefinitions ().Create (ctx , & apiv1.ResourceDefinition {Name : "pvc-shared" }); err != nil {
622+ t .Fatalf ("seed RD: %v" , err )
623+ }
624+
625+ for _ , n := range []string {"n1" , "n2" , "n3" } {
626+ if err := st .Nodes ().Create (ctx , & apiv1.Node {Name : n , Type : apiv1 .NodeTypeSatellite }); err != nil {
627+ t .Fatalf ("seed node %s: %v" , n , err )
628+ }
629+ }
630+
631+ // n1 + n2 each see the same EXOS LUN; n3 has its own local pool.
632+ pools := []apiv1.StoragePool {
633+ {StoragePoolName : "pool" , NodeName : "n1" , ProviderKind : apiv1 .StoragePoolKindLVMThin , SharedSpaceID : "exos-lun-42" , FreeCapacity : 9000 },
634+ {StoragePoolName : "pool" , NodeName : "n2" , ProviderKind : apiv1 .StoragePoolKindLVMThin , SharedSpaceID : "exos-lun-42" , FreeCapacity : 9000 },
635+ {StoragePoolName : "pool" , NodeName : "n3" , ProviderKind : apiv1 .StoragePoolKindLVMThin , FreeCapacity : 5000 },
636+ }
637+ for i := range pools {
638+ if err := st .StoragePools ().Create (ctx , & pools [i ]); err != nil {
639+ t .Fatalf ("seed pool: %v" , err )
640+ }
641+ }
642+
643+ base , stop := startServerWithStore (t , st )
644+ defer stop ()
645+
646+ body , _ := json .Marshal (apiv1.AutoPlaceRequest {
647+ SelectFilter : apiv1.AutoSelectFilter {PlaceCount : 2 , StoragePool : "pool" },
648+ })
649+
650+ resp := httpPost (t , base + "/v1/resource-definitions/pvc-shared/autoplace" , body )
651+ _ = resp .Body .Close ()
652+
653+ if resp .StatusCode != http .StatusOK {
654+ t .Fatalf ("status: got %d, want 200" , resp .StatusCode )
655+ }
656+
657+ got , err := st .Resources ().ListByDefinition (ctx , "pvc-shared" )
658+ if err != nil {
659+ t .Fatalf ("list: %v" , err )
660+ }
661+
662+ if len (got ) != 2 {
663+ t .Fatalf ("placed: got %d, want 2" , len (got ))
664+ }
665+
666+ // Exactly one of {n1, n2} can be picked; n3 (local) MUST be the other.
667+ nodes := []string {got [0 ].NodeName , got [1 ].NodeName }
668+ if ! slices .Contains (nodes , "n3" ) {
669+ t .Errorf ("expected one replica on n3 (the non-shared node); got %v" , nodes )
670+ }
671+
672+ sharedHit := 0
673+ for _ , n := range nodes {
674+ if n == "n1" || n == "n2" {
675+ sharedHit ++
676+ }
677+ }
678+
679+ if sharedHit > 1 {
680+ t .Errorf ("both replicas on the same shared-LUN pool group: %v" , nodes )
681+ }
682+ }
683+
684+ // TestAutoplaceSharedLUNExhausted: 2-replica RD against two pools
685+ // sharing one LUN — only one replica fits, the other has no
686+ // candidate and the request must 409. Pins the conflict path.
687+ func TestAutoplaceSharedLUNExhausted (t * testing.T ) {
688+ st := store .NewInMemory ()
689+ ctx := t .Context ()
690+
691+ if err := st .ResourceDefinitions ().Create (ctx , & apiv1.ResourceDefinition {Name : "pvc-shared-2" }); err != nil {
692+ t .Fatalf ("seed RD: %v" , err )
693+ }
694+
695+ for _ , n := range []string {"n1" , "n2" } {
696+ if err := st .Nodes ().Create (ctx , & apiv1.Node {Name : n , Type : apiv1 .NodeTypeSatellite }); err != nil {
697+ t .Fatalf ("seed node %s: %v" , n , err )
698+ }
699+
700+ if err := st .StoragePools ().Create (ctx , & apiv1.StoragePool {
701+ StoragePoolName : "pool" ,
702+ NodeName : n ,
703+ ProviderKind : apiv1 .StoragePoolKindLVMThin ,
704+ SharedSpaceID : "exos-lun-42" ,
705+ }); err != nil {
706+ t .Fatalf ("seed pool %s: %v" , n , err )
707+ }
708+ }
709+
710+ base , stop := startServerWithStore (t , st )
711+ defer stop ()
712+
713+ body , _ := json .Marshal (apiv1.AutoPlaceRequest {
714+ SelectFilter : apiv1.AutoSelectFilter {PlaceCount : 2 , StoragePool : "pool" },
715+ })
716+
717+ resp := httpPost (t , base + "/v1/resource-definitions/pvc-shared-2/autoplace" , body )
718+ _ = resp .Body .Close ()
719+
720+ if resp .StatusCode != http .StatusConflict {
721+ t .Errorf ("status: got %d, want 409 (only one shared-LUN slot fits)" , resp .StatusCode )
722+ }
723+ }
724+
611725// TestResourceListAndGet: GET /v1/resource-definitions/{rd}/resources
612726// returns all replicas wrapped as ResourceWithVolumes; the per-node
613727// GET returns one entry or 404 when missing. linstor-csi's reconciler
0 commit comments