@@ -652,6 +652,126 @@ func TestAllocationHandler_Create(t *testing.T) {
652652 }
653653}
654654
655+ // TestAllocationHandler_Create_GlobalOSAutoAssociationIdempotent verifies that
656+ // creating an Allocation for a tenant that previously had (and lost) access to a
657+ // Site does not fail because the OperatingSystemSiteAssociation from the earlier
658+ // allocation already exists.
659+ //
660+ // Scenario:
661+ // 1. Tenant has a global-scoped IPXE OS.
662+ // 2. First Allocation → TenantSite is created → OS is auto-associated with Site.
663+ // 3. TenantSite is deleted to simulate all Allocations being removed.
664+ // 4. Second Allocation (same tenant + site) → TenantSite is recreated → OS
665+ // auto-association must be skipped (not fail) because the row still exists.
666+ func TestAllocationHandler_Create_GlobalOSAutoAssociationIdempotent (t * testing.T ) {
667+ ctx := context .Background ()
668+ dbSession := testMachineInitDB (t )
669+ defer dbSession .Close ()
670+
671+ common .TestSetupSchema (t , dbSession )
672+
673+ ipOrg := "test-ip-org-idempotent"
674+ tnOrg := "test-tn-org-idempotent"
675+
676+ ipu := testMachineBuildUser (t , dbSession , uuid .New ().String (), []string {ipOrg }, []string {"FORGE_PROVIDER_ADMIN" })
677+ tnu := testMachineBuildUser (t , dbSession , uuid .New ().String (), []string {tnOrg }, []string {"FORGE_TENANT_ADMIN" })
678+
679+ ip := common .TestBuildInfrastructureProvider (t , dbSession , "TestIpIdempotent" , ipOrg , ipu )
680+ site := testIPBlockBuildSite (t , dbSession , ip , "testSiteIdempotent" , cdbm .SiteStatusRegistered , true , ipu )
681+ tenant := testMachineBuildTenant (t , dbSession , tnOrg , "t-idempotent" )
682+
683+ it := common .TestBuildInstanceType (t , dbSession , "testITIdempotent" , cdb .GetUUIDPtr (uuid .New ()), site , map [string ]string {
684+ "name" : "test-instance-type-idempotent" ,
685+ "description" : "Idempotent test instance type" ,
686+ }, ipu )
687+ for i := 0 ; i < 5 ; i ++ {
688+ mc := testInstanceBuildMachine (t , dbSession , ip .ID , site .ID , cdb .GetBoolPtr (false ), nil )
689+ require .NotNil (t , mc )
690+ require .NotNil (t , testInstanceBuildMachineInstanceType (t , dbSession , mc , it ))
691+ }
692+
693+ ipb := testIPBlockBuildIPBlock (t , dbSession , "testipb-idempotent" , site , ip , & tenant .ID ,
694+ cdbm .IPBlockRoutingTypeDatacenterOnly , "10.99.0.0" , 16 , cdbm .IPBlockProtocolVersionV4 ,
695+ false , cdbm .IPBlockStatusReady , ipu )
696+
697+ ipamStorage := ipam .NewIpamStorage (dbSession .DB , nil )
698+ _ , err := ipam .CreateIpamEntryForIPBlock (ctx , ipamStorage , ipb .Prefix , ipb .PrefixLength ,
699+ ipb .RoutingType , ipb .InfrastructureProviderID .String (), ipb .SiteID .String ())
700+ require .NoError (t , err )
701+
702+ // A tenant-owned global-scoped IPXE OS — this is what the auto-association code targets.
703+ globalScope := cdbm .OperatingSystemScopeGlobal
704+ globalOS := & cdbm.OperatingSystem {
705+ ID : uuid .New (),
706+ Name : "global-os-idempotent" ,
707+ TenantID : cdb .GetUUIDPtr (tenant .ID ),
708+ Type : cdbm .OperatingSystemTypeIPXE ,
709+ IpxeOsScope : & globalScope ,
710+ IpxeScript : cdb .GetStrPtr (common .DefaultIpxeScript ),
711+ IsActive : true ,
712+ Status : cdbm .OperatingSystemStatusReady ,
713+ CreatedBy : tnu .ID ,
714+ }
715+ _ , err = dbSession .DB .NewInsert ().Model (globalOS ).Exec (ctx )
716+ require .NoError (t , err )
717+
718+ ac := model.APIAllocationConstraintCreateRequest {
719+ ResourceType : cdbm .AllocationResourceTypeInstanceType ,
720+ ResourceTypeID : it .ID .String (),
721+ ConstraintType : cdbm .AllocationConstraintTypeReserved ,
722+ ConstraintValue : 2 ,
723+ }
724+ body , err := json .Marshal (model.APIAllocationCreateRequest {
725+ Name : "alloc-idempotent-1" ,
726+ Description : cdb .GetStrPtr ("" ),
727+ TenantID : tenant .ID .String (),
728+ SiteID : site .ID .String (),
729+ AllocationConstraints : []model.APIAllocationConstraintCreateRequest {ac },
730+ })
731+ require .NoError (t , err )
732+
733+ // First allocation: TenantSite is created and the global OS is auto-associated.
734+ a1 := testCreateAllocation (t , dbSession , ipamStorage , ipu , ipOrg , string (body ))
735+ require .NotNil (t , a1 )
736+
737+ ossaDAO := cdbm .NewOperatingSystemSiteAssociationDAO (dbSession )
738+ _ , err = ossaDAO .GetByOperatingSystemIDAndSiteID (ctx , nil , globalOS .ID , site .ID , nil )
739+ require .NoError (t , err , "OS-site association must exist after first allocation" )
740+
741+ // Simulate all Allocations being removed: delete the TenantSite record so that
742+ // the next Allocation triggers TenantSite (and OS auto-association) logic again.
743+ _ , err = dbSession .DB .NewDelete ().TableExpr ("tenant_site" ).
744+ Where ("tenant_id = ? AND site_id = ?" , tenant .ID , site .ID ).Exec (ctx )
745+ require .NoError (t , err )
746+
747+ body2 , err := json .Marshal (model.APIAllocationCreateRequest {
748+ Name : "alloc-idempotent-2" ,
749+ Description : cdb .GetStrPtr ("" ),
750+ TenantID : tenant .ID .String (),
751+ SiteID : site .ID .String (),
752+ AllocationConstraints : []model.APIAllocationConstraintCreateRequest {ac },
753+ })
754+ require .NoError (t , err )
755+
756+ // Second allocation on the same site: must succeed even though the
757+ // OperatingSystemSiteAssociation from the first allocation still exists.
758+ a2 := testCreateAllocation (t , dbSession , ipamStorage , ipu , ipOrg , string (body2 ))
759+ require .NotNil (t , a2 , "second allocation must succeed when OS-site association already exists" )
760+
761+ // The association should still exist exactly once (not duplicated).
762+ ossas , ossaCount , err := ossaDAO .GetAll (ctx , nil ,
763+ cdbm.OperatingSystemSiteAssociationFilterInput {
764+ OperatingSystemIDs : []uuid.UUID {globalOS .ID },
765+ SiteIDs : []uuid.UUID {site .ID },
766+ },
767+ cdbp.PageInput {},
768+ nil ,
769+ )
770+ require .NoError (t , err )
771+ assert .Equal (t , 1 , ossaCount , "OS-site association must exist exactly once after both allocations" )
772+ _ = ossas
773+ }
774+
655775func testCreateAllocation (t * testing.T , dbSession * cdb.Session , ipamStorage cipam.Storage , user * cdbm.User , reqOrgName , reqBody string ) * model.APIAllocation {
656776 ctx := context .Background ()
657777 e := echo .New ()
0 commit comments