diff --git a/api/pkg/api/handler/allocation.go b/api/pkg/api/handler/allocation.go index cf6271938..f51adb5df 100644 --- a/api/pkg/api/handler/allocation.go +++ b/api/pkg/api/handler/allocation.go @@ -458,6 +458,46 @@ func (cah CreateAllocationHandler) Handle(c echo.Context) error { } else { logger.Info().Str("Workflow ID", we.GetID()).Msg("triggered workflow to create Tenant") } + + // Auto-associate tenant-owned Global-scoped iPXE OSes (both raw and + // Templated) with the new site. This mirrors the provider-side + // auto-expansion in the Site create handler. + globalTenantOSes, _, goserr := cdbm.NewOperatingSystemDAO(cah.dbSession).GetAll( + ctx, tx, + cdbm.OperatingSystemFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + OsTypes: []string{cdbm.OperatingSystemTypeIPXE, cdbm.OperatingSystemTypeTemplatedIPXE}, + Scopes: []string{cdbm.OperatingSystemScopeGlobal}, + }, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if goserr != nil { + logger.Error().Err(goserr).Msg("error retrieving tenant global-scoped OSes for auto-expansion") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve global-scoped OSes for tenant, DB error", nil) + } + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(cah.dbSession) + for _, gos := range globalTenantOSes { + _, aerr := ossaDAO.GetByOperatingSystemIDAndSiteID(ctx, tx, gos.ID, site.ID, nil) + if aerr != nil && aerr != cdb.ErrDoesNotExist { + logger.Error().Err(aerr).Str("osID", gos.ID.String()).Msg("Failed to check existing OS-site association") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to associate global-scoped Operating Systems with new Site", nil) + } + if aerr == cdb.ErrDoesNotExist { + if _, aerr = ossaDAO.Create(ctx, tx, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: gos.ID, + SiteID: site.ID, + Status: cdbm.OperatingSystemSiteAssociationStatusSyncing, + CreatedBy: dbUser.ID, + }); aerr != nil { + logger.Error().Err(aerr).Str("osID", gos.ID.String()).Msg("Failed to auto-associate tenant global OS with new site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to associate global-scoped Operating Systems with new Site", nil) + } + } + } + if len(globalTenantOSes) > 0 { + logger.Info().Int("count", len(globalTenantOSes)).Msg("Auto-associated tenant global-scoped OSes with new site") + } } // Commit transaction diff --git a/api/pkg/api/handler/allocation_test.go b/api/pkg/api/handler/allocation_test.go index bc03d43ee..e5db07a03 100644 --- a/api/pkg/api/handler/allocation_test.go +++ b/api/pkg/api/handler/allocation_test.go @@ -652,6 +652,126 @@ func TestAllocationHandler_Create(t *testing.T) { } } +// TestAllocationHandler_Create_GlobalOSAutoAssociationIdempotent verifies that +// creating an Allocation for a tenant that previously had (and lost) access to a +// Site does not fail because the OperatingSystemSiteAssociation from the earlier +// allocation already exists. +// +// Scenario: +// 1. Tenant has a global-scoped IPXE OS. +// 2. First Allocation → TenantSite is created → OS is auto-associated with Site. +// 3. TenantSite is deleted to simulate all Allocations being removed. +// 4. Second Allocation (same tenant + site) → TenantSite is recreated → OS +// auto-association must be skipped (not fail) because the row still exists. +func TestAllocationHandler_Create_GlobalOSAutoAssociationIdempotent(t *testing.T) { + ctx := context.Background() + dbSession := testMachineInitDB(t) + defer dbSession.Close() + + common.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org-idempotent" + tnOrg := "test-tn-org-idempotent" + + ipu := testMachineBuildUser(t, dbSession, uuid.New().String(), []string{ipOrg}, []string{"FORGE_PROVIDER_ADMIN"}) + tnu := testMachineBuildUser(t, dbSession, uuid.New().String(), []string{tnOrg}, []string{"FORGE_TENANT_ADMIN"}) + + ip := common.TestBuildInfrastructureProvider(t, dbSession, "TestIpIdempotent", ipOrg, ipu) + site := testIPBlockBuildSite(t, dbSession, ip, "testSiteIdempotent", cdbm.SiteStatusRegistered, true, ipu) + tenant := testMachineBuildTenant(t, dbSession, tnOrg, "t-idempotent") + + it := common.TestBuildInstanceType(t, dbSession, "testITIdempotent", cdb.GetUUIDPtr(uuid.New()), site, map[string]string{ + "name": "test-instance-type-idempotent", + "description": "Idempotent test instance type", + }, ipu) + for i := 0; i < 5; i++ { + mc := testInstanceBuildMachine(t, dbSession, ip.ID, site.ID, cdb.GetBoolPtr(false), nil) + require.NotNil(t, mc) + require.NotNil(t, testInstanceBuildMachineInstanceType(t, dbSession, mc, it)) + } + + ipb := testIPBlockBuildIPBlock(t, dbSession, "testipb-idempotent", site, ip, &tenant.ID, + cdbm.IPBlockRoutingTypeDatacenterOnly, "10.99.0.0", 16, cdbm.IPBlockProtocolVersionV4, + false, cdbm.IPBlockStatusReady, ipu) + + ipamStorage := ipam.NewIpamStorage(dbSession.DB, nil) + _, err := ipam.CreateIpamEntryForIPBlock(ctx, ipamStorage, ipb.Prefix, ipb.PrefixLength, + ipb.RoutingType, ipb.InfrastructureProviderID.String(), ipb.SiteID.String()) + require.NoError(t, err) + + // A tenant-owned global-scoped IPXE OS — this is what the auto-association code targets. + globalScope := cdbm.OperatingSystemScopeGlobal + globalOS := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "global-os-idempotent", + TenantID: cdb.GetUUIDPtr(tenant.ID), + Type: cdbm.OperatingSystemTypeIPXE, + IpxeOsScope: &globalScope, + IpxeScript: cdb.GetStrPtr(common.DefaultIpxeScript), + IsActive: true, + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: tnu.ID, + } + _, err = dbSession.DB.NewInsert().Model(globalOS).Exec(ctx) + require.NoError(t, err) + + ac := model.APIAllocationConstraintCreateRequest{ + ResourceType: cdbm.AllocationResourceTypeInstanceType, + ResourceTypeID: it.ID.String(), + ConstraintType: cdbm.AllocationConstraintTypeReserved, + ConstraintValue: 2, + } + body, err := json.Marshal(model.APIAllocationCreateRequest{ + Name: "alloc-idempotent-1", + Description: cdb.GetStrPtr(""), + TenantID: tenant.ID.String(), + SiteID: site.ID.String(), + AllocationConstraints: []model.APIAllocationConstraintCreateRequest{ac}, + }) + require.NoError(t, err) + + // First allocation: TenantSite is created and the global OS is auto-associated. + a1 := testCreateAllocation(t, dbSession, ipamStorage, ipu, ipOrg, string(body)) + require.NotNil(t, a1) + + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) + _, err = ossaDAO.GetByOperatingSystemIDAndSiteID(ctx, nil, globalOS.ID, site.ID, nil) + require.NoError(t, err, "OS-site association must exist after first allocation") + + // Simulate all Allocations being removed: delete the TenantSite record so that + // the next Allocation triggers TenantSite (and OS auto-association) logic again. + _, err = dbSession.DB.NewDelete().TableExpr("tenant_site"). + Where("tenant_id = ? AND site_id = ?", tenant.ID, site.ID).Exec(ctx) + require.NoError(t, err) + + body2, err := json.Marshal(model.APIAllocationCreateRequest{ + Name: "alloc-idempotent-2", + Description: cdb.GetStrPtr(""), + TenantID: tenant.ID.String(), + SiteID: site.ID.String(), + AllocationConstraints: []model.APIAllocationConstraintCreateRequest{ac}, + }) + require.NoError(t, err) + + // Second allocation on the same site: must succeed even though the + // OperatingSystemSiteAssociation from the first allocation still exists. + a2 := testCreateAllocation(t, dbSession, ipamStorage, ipu, ipOrg, string(body2)) + require.NotNil(t, a2, "second allocation must succeed when OS-site association already exists") + + // The association should still exist exactly once (not duplicated). + ossas, ossaCount, err := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{globalOS.ID}, + SiteIDs: []uuid.UUID{site.ID}, + }, + cdbp.PageInput{}, + nil, + ) + require.NoError(t, err) + assert.Equal(t, 1, ossaCount, "OS-site association must exist exactly once after both allocations") + _ = ossas +} + func testCreateAllocation(t *testing.T, dbSession *cdb.Session, ipamStorage cipam.Storage, user *cdbm.User, reqOrgName, reqBody string) *model.APIAllocation { ctx := context.Background() e := echo.New() diff --git a/api/pkg/api/handler/dpuextensionservice.go b/api/pkg/api/handler/dpuextensionservice.go index d7642f011..f75e5eb32 100644 --- a/api/pkg/api/handler/dpuextensionservice.go +++ b/api/pkg/api/handler/dpuextensionservice.go @@ -293,6 +293,7 @@ func (cdesh CreateDpuExtensionServiceHandler) Handle(c echo.Context) error { if err != nil { var timeoutErr *tp.TimeoutError if errors.As(err, &timeoutErr) { + // TODO: Terminate the workflow logger.Error().Err(err).Msg("timed out executing DPU Extension Service creation workflow on Site") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Timed out executing DPU Extension Service creation workflow on Site: %s", err), nil) } diff --git a/api/pkg/api/handler/instance.go b/api/pkg/api/handler/instance.go index 648e13e6c..738ac690d 100644 --- a/api/pkg/api/handler/instance.go +++ b/api/pkg/api/handler/instance.go @@ -80,7 +80,7 @@ func NewCreateInstanceHandler(dbSession *cdb.Session, tc temporalClient.Client, // apiRequest will be mutated for use in createFromParams. // osConfig will hold the struct/data for use with Temporal/Carbide calls. // Errors should be returned in the form of cutil.NewAPIErrorResponse -func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Context, logger *zerolog.Logger, apiRequest *model.APIInstanceCreateRequest, site *cdbm.Site) (*cwssaws.OperatingSystem, *uuid.UUID, *cutil.APIError) { +func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Context, logger *zerolog.Logger, apiRequest *model.APIInstanceCreateRequest, site *cdbm.Site, tenant *cdbm.Tenant) (*cwssaws.InstanceOperatingSystemConfig, *uuid.UUID, *cutil.APIError) { ctx := c.Request().Context() @@ -92,10 +92,10 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "Failed to validate OperatingSystem data", err) } - return &cwssaws.OperatingSystem{ + return &cwssaws.InstanceOperatingSystemConfig{ RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, // Set by the earlier call to ValidateAndSetOperatingSystemData PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, // Set by the earlier call to ValidateAndSetOperatingSystemData - Variant: &cwssaws.OperatingSystem_Ipxe{ + Variant: &cwssaws.InstanceOperatingSystemConfig_Ipxe{ Ipxe: &cwssaws.InlineIpxe{ IpxeScript: *apiRequest.IpxeScript, }, @@ -138,8 +138,8 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte return c.Str("OperatingSystem ID", os.ID.String()) }) - // Confirm ownership between tenant and OS. - if os.TenantID.String() != apiRequest.TenantID { + // Tenant can use this OS if they created it or it is offered by Provider + if !(os.TenantID == nil || *os.TenantID == tenant.ID) { logger.Error().Msg("OperatingSystem in request is not owned by tenant") return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "OperatingSystem specified in request is not owned by Tenant", nil) } @@ -149,10 +149,8 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte logger.Warn().Str("operatingSystemId", os.ID.String()).Str("siteId", site.ID.String()).Msg("Creation of Instance with Image based Operating System is not supported for Site, ImageBasedOperatingSystem capability is not enabled") return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "Creation of Instance with Image based Operating System is not supported. Site must have ImageBasedOperatingSystem capability enabled.", nil) } - } - // Confirm match between site and OS (only for Image type). - if os.Type == cdbm.OperatingSystemTypeImage { + // Confirm match between site and OS. ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(cih.dbSession) _, ossaCount, err := ossaDAO.GetAll( ctx, @@ -188,29 +186,27 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte // Options below should all have been set by the // earlier call to ValidateAndSetOperatingSystemData - - if os.Type == cdbm.OperatingSystemTypeIPXE { - return &cwssaws.OperatingSystem{ - RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.OperatingSystem_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *apiRequest.IpxeScript, - }, - }, - UserData: apiRequest.UserData, - }, osID, nil - } else { - return &cwssaws.OperatingSystem{ - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.OperatingSystem_OsImageId{ - OsImageId: &cwssaws.UUID{ - Value: os.ID.String(), - }, - }, - UserData: apiRequest.UserData, - }, osID, nil + result := cwssaws.InstanceOperatingSystemConfig{ + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + UserData: apiRequest.UserData, + } + switch os.Type { + case cdbm.OperatingSystemTypeIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *apiRequest.IpxeScript}, + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + } + case cdbm.OperatingSystemTypeImage: + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OsImageId{ + OsImageId: &cwssaws.UUID{Value: os.ID.String()}, + } } + return &result, osID, nil } // Handle godoc @@ -325,19 +321,18 @@ func (cih CreateInstanceHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) } - // Deprecated: tenantId in request body. Infer from org when not provided. - if apiRequest.TenantID == "" { - apiRequest.TenantID = tenant.ID.String() - } - - apiTenant, err := common.GetTenantFromIDString(ctx, nil, apiRequest.TenantID, cih.dbSession) - if err != nil { - logger.Warn().Err(err).Msg("error retrieving tenant from request") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request is not valid", nil) - } - if apiTenant.ID != tenant.ID { - logger.Warn().Msg("tenant id in request does not match tenant in org") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request does not match tenant in org", nil) + // If the caller provided an explicit tenantId in the body, validate it matches the org. + // TODO: tenantId as parameter is deprecated and will need to be removed by 2026-10-01. + if apiRequest.TenantID != "" { + apiTenant, terr := common.GetTenantFromIDString(ctx, nil, apiRequest.TenantID, cih.dbSession) + if terr != nil { + logger.Warn().Err(terr).Msg("error retrieving tenant from request") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request is not valid", nil) + } + if apiTenant.ID != tenant.ID { + logger.Warn().Msg("tenant id in request does not match tenant in org") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request does not match tenant in org", nil) + } } // Validate the VPC state @@ -751,7 +746,7 @@ func (cih CreateInstanceHandler) Handle(c echo.Context) error { // apiRequest will be mutated for use in CreateFromParams. // osConfig will hold the struct/data for use with Temporal/Carbide calls. // Errors will be returned already in the form of cutil.NewAPIErrorResponse - osConfig, osID, oserr := cih.buildInstanceCreateRequestOsConfig(c, &logger, &apiRequest, site) + osConfig, osID, oserr := cih.buildInstanceCreateRequestOsConfig(c, &logger, &apiRequest, site, tenant) if oserr != nil { // buildInstanceCreateRequestOsConfig already handles logging, // so this is a bit redundant, but this log brings you to the @@ -1836,7 +1831,7 @@ func (uih UpdateInstanceHandler) handleReboot(c echo.Context, logger *zerolog.Lo // apiRequest will be mutated for use in UpdateFromParams. // osConfig will hold the struct/data for use with Temporal/Carbide calls. // Errors should be returned in the form of cutil.NewAPIErrorResponse -func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Context, logger *zerolog.Logger, apiRequest *model.APIInstanceUpdateRequest, instance *cdbm.Instance, site *cdbm.Site) (*cwssaws.OperatingSystem, *uuid.UUID, *cutil.APIError) { +func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Context, logger *zerolog.Logger, apiRequest *model.APIInstanceUpdateRequest, instance *cdbm.Instance, site *cdbm.Site) (*cwssaws.InstanceOperatingSystemConfig, *uuid.UUID, *cutil.APIError) { var os *cdbm.OperatingSystem var osID *uuid.UUID @@ -1851,10 +1846,10 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "Failed to validate OperatingSystem data", err) } - return &cwssaws.OperatingSystem{ + return &cwssaws.InstanceOperatingSystemConfig{ RunProvisioningInstructionsOnEveryBoot: instance.AlwaysBootWithCustomIpxe, PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, // Set by the earlier call to ValidateAndSetOperatingSystemData - Variant: &cwssaws.OperatingSystem_Ipxe{ + Variant: &cwssaws.InstanceOperatingSystemConfig_Ipxe{ Ipxe: &cwssaws.InlineIpxe{ IpxeScript: *apiRequest.IpxeScript, }, @@ -1908,7 +1903,8 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte }) // Confirm ownership between tenant and OS. - if os.TenantID.String() != instance.Tenant.ID.String() { + // Provider-owned OS (TenantID is nil) is accessible to any tenant. + if !(os.TenantID == nil || *os.TenantID == instance.TenantID) { logger.Error().Msg("OperatingSystem in request is not owned by tenant") return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "Operating system specified in request is not owned by Tenant", nil) } @@ -1919,10 +1915,8 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte logger.Warn().Str("operatingSystemId", os.ID.String()).Str("siteId", site.ID.String()).Msg("Instance update with Image based Operating System is not supported for Site, ImageBasedOperatingSystem capability is not enabled") return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "Update of Instance with Image based Operating System is not supported. Site must have ImageBasedOperatingSystem capability enabled.", nil) } - } - // Confirm match between site and OS (only for Image type). - if os.Type == cdbm.OperatingSystemTypeImage { + // Confirm match between site and OS. ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(uih.dbSession) _, ossaCount, err := ossaDAO.GetAll( ctx, @@ -1947,9 +1941,15 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte } } - // reject deactivated OS except if OS stays the same: + // Reject deactivated OS except when the OS stays the same (caller did not + // supply operatingSystemId, or supplied the same id the instance already has). + // In particular, if the instance currently has no OS and the caller is now + // selecting a deactivated OS, that is treated as an explicit change and rejected. if os != nil && !os.IsActive { - if apiRequest.OperatingSystemID != nil && instance.OperatingSystemID != nil && *apiRequest.OperatingSystemID != instance.OperatingSystemID.String() { + isExplicitChange := apiRequest.OperatingSystemID != nil && + (instance.OperatingSystemID == nil || + *apiRequest.OperatingSystemID != instance.OperatingSystemID.String()) + if isExplicitChange { return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "Operating System specified in request has been deactivated and cannot be used to update an instance", nil) } } @@ -1991,41 +1991,35 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte phoneHomeEnabled = *apiRequest.PhoneHomeEnabled } + result := cwssaws.InstanceOperatingSystemConfig{ + PhoneHomeEnabled: phoneHomeEnabled, + UserData: userData, + } if os != nil { - if os.Type == cdbm.OperatingSystemTypeIPXE { - return &cwssaws.OperatingSystem{ - RunProvisioningInstructionsOnEveryBoot: alwaysBootWithCustomIpxe, - PhoneHomeEnabled: phoneHomeEnabled, - Variant: &cwssaws.OperatingSystem_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *ipxeScript, - }, - }, - UserData: userData, - }, osID, nil - } else if os.Type == cdbm.OperatingSystemTypeImage { - return &cwssaws.OperatingSystem{ - PhoneHomeEnabled: phoneHomeEnabled, - Variant: &cwssaws.OperatingSystem_OsImageId{ - OsImageId: &cwssaws.UUID{ - Value: os.ID.String(), - }, - }, - UserData: userData, - }, osID, nil + switch os.Type { + case cdbm.OperatingSystemTypeIPXE: + result.RunProvisioningInstructionsOnEveryBoot = alwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *ipxeScript}, + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + result.RunProvisioningInstructionsOnEveryBoot = alwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + } + case cdbm.OperatingSystemTypeImage: + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OsImageId{ + OsImageId: &cwssaws.UUID{Value: os.ID.String()}, + } + } + } else { + result.RunProvisioningInstructionsOnEveryBoot = alwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *ipxeScript}, } } - return &cwssaws.OperatingSystem{ - RunProvisioningInstructionsOnEveryBoot: alwaysBootWithCustomIpxe, - PhoneHomeEnabled: phoneHomeEnabled, - Variant: &cwssaws.OperatingSystem_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *ipxeScript, - }, - }, - UserData: userData, - }, osID, nil + return &result, osID, nil } // Handle godoc diff --git a/api/pkg/api/handler/instance_test.go b/api/pkg/api/handler/instance_test.go index 77f13618b..a21ba76f3 100644 --- a/api/pkg/api/handler/instance_test.go +++ b/api/pkg/api/handler/instance_test.go @@ -328,7 +328,30 @@ func testInstanceBuildOperatingSystem(t *testing.T, dbSession *cdb.Session, name } // If iPXE, the OS should have a script set. - if osType == cdbm.OperatingSystemTypeIPXE { + if cdbm.IsIPXEType(osType) { + operatingSystem.IpxeScript = cdb.GetStrPtr(common.DefaultIpxeScript) + } + + _, err := dbSession.DB.NewInsert().Model(operatingSystem).Exec(context.Background()) + assert.Nil(t, err) + return operatingSystem +} + +func testInstanceBuildProviderOperatingSystem(t *testing.T, dbSession *cdb.Session, name string, ip *cdbm.InfrastructureProvider, osType string, allowOverride bool, userData *string, phoneHomeEnabled bool, status string, user *cdbm.User) *cdbm.OperatingSystem { + operatingSystem := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: name, + InfrastructureProviderID: cdb.GetUUIDPtr(ip.ID), + Type: osType, + AllowOverride: allowOverride, + PhoneHomeEnabled: phoneHomeEnabled, + IsActive: true, + UserData: userData, + Status: status, + CreatedBy: user.ID, + } + + if cdbm.IsIPXEType(osType) { operatingSystem.IpxeScript = cdb.GetStrPtr(common.DefaultIpxeScript) } @@ -760,7 +783,7 @@ func TestCreateInstanceHandler_Handle(t *testing.T) { assert.NotNil(t, mcmissing) testUpdateMachineToMissing(t, dbSession, mcmissing) - alc1 := testInstanceSiteBuildAllocationContraints(t, dbSession, al1, cdbm.AllocationResourceTypeInstanceType, ist1.ID, cdbm.AllocationConstraintTypeReserved, 9, ipu) + alc1 := testInstanceSiteBuildAllocationContraints(t, dbSession, al1, cdbm.AllocationResourceTypeInstanceType, ist1.ID, cdbm.AllocationConstraintTypeReserved, 15, ipu) assert.NotNil(t, alc1) mc1 := testInstanceBuildMachine(t, dbSession, ip.ID, st1.ID, cdb.GetBoolPtr(false), nil) @@ -823,6 +846,18 @@ func TestCreateInstanceHandler_Handle(t *testing.T) { mcinst20 := testInstanceBuildMachineInstanceType(t, dbSession, mc20, ist1) assert.NotNil(t, mcinst20) + mc21 := testInstanceBuildMachine(t, dbSession, ip.ID, st1.ID, cdb.GetBoolPtr(false), nil) + assert.NotNil(t, mc21) + assert.NotNil(t, testInstanceBuildMachineInstanceType(t, dbSession, mc21, ist1)) + + mc22 := testInstanceBuildMachine(t, dbSession, ip.ID, st1.ID, cdb.GetBoolPtr(false), nil) + assert.NotNil(t, mc22) + assert.NotNil(t, testInstanceBuildMachineInstanceType(t, dbSession, mc22, ist1)) + + mc23 := testInstanceBuildMachine(t, dbSession, ip.ID, st1.ID, cdb.GetBoolPtr(false), nil) + assert.NotNil(t, mc23) + assert.NotNil(t, testInstanceBuildMachineInstanceType(t, dbSession, mc23, ist1)) + // Tenant 1 os1 := testInstanceBuildOperatingSystem(t, dbSession, "test-operating-system-1", tn1, cdbm.OperatingSystemTypeIPXE, false, nil, true, cdbm.OperatingSystemStatusReady, tnu1) assert.NotNil(t, os1) @@ -835,6 +870,33 @@ func TestCreateInstanceHandler_Handle(t *testing.T) { osPhoneHome := testInstanceBuildOperatingSystem(t, dbSession, "test-operating-system-phonehome", tn1, cdbm.OperatingSystemTypeIPXE, true, cdb.GetStrPtr(""), true, cdbm.OperatingSystemStatusReady, tnu1) assert.NotNil(t, osPhoneHome) + // Templated iPXE OS - tenant-owned, no override + osTemplated1 := testInstanceBuildOperatingSystem(t, dbSession, "test-operating-system-templated-1", tn1, cdbm.OperatingSystemTypeTemplatedIPXE, false, nil, false, cdbm.OperatingSystemStatusReady, tnu1) + assert.NotNil(t, osTemplated1) + osTemplated1.IpxeTemplateId = cdb.GetStrPtr("test-template") + _, errOsT1 := dbSession.DB.NewUpdate().Model(osTemplated1).Column("ipxe_template_id").WherePK().Exec(context.Background()) + assert.NoError(t, errOsT1) + + // Templated iPXE OS - tenant-owned, override allowed + osTemplated2 := testInstanceBuildOperatingSystem(t, dbSession, "test-operating-system-templated-2", tn1, cdbm.OperatingSystemTypeTemplatedIPXE, true, cdb.GetStrPtr(cdmu.TestCommonCloudInit), false, cdbm.OperatingSystemStatusReady, tnu1) + assert.NotNil(t, osTemplated2) + osTemplated2.IpxeTemplateId = cdb.GetStrPtr("test-template-2") + _, errOsT2 := dbSession.DB.NewUpdate().Model(osTemplated2).Column("ipxe_template_id").WherePK().Exec(context.Background()) + assert.NoError(t, errOsT2) + + // Templated iPXE OS - deactivated + osTemplatedDeactivated := testInstanceBuildOperatingSystem(t, dbSession, "test-operating-system-templated-deactivated", tn1, cdbm.OperatingSystemTypeTemplatedIPXE, false, nil, false, cdbm.OperatingSystemStatusReady, tnu1) + assert.NotNil(t, osTemplatedDeactivated) + osTemplatedDeactivated.IsActive = false + testUpdateOSIsActive(t, dbSession, osTemplatedDeactivated) + + // Provider-owned iPXE OS (Templated) + osProviderTemplated := testInstanceBuildProviderOperatingSystem(t, dbSession, "test-operating-system-provider-templated", ip, cdbm.OperatingSystemTypeTemplatedIPXE, false, nil, false, cdbm.OperatingSystemStatusReady, ipu) + assert.NotNil(t, osProviderTemplated) + osProviderTemplated.IpxeTemplateId = cdb.GetStrPtr("provider-template") + _, errOsProv := dbSession.DB.NewUpdate().Model(osProviderTemplated).Column("ipxe_template_id").WherePK().Exec(context.Background()) + assert.NoError(t, errOsProv) + // create a default NVLink Logical Partition nvllpDefault := testBuildNVLinkLogicalPartition(t, dbSession, "test-nvllp-default", cdb.GetStrPtr("Test NVLink Logical Partition"), tnOrg, st1, tn1, cdb.GetStrPtr(cdbm.NVLinkLogicalPartitionStatusReady), false) assert.NotNil(t, nvllpDefault) @@ -2321,6 +2383,211 @@ func TestCreateInstanceHandler_Handle(t *testing.T) { }, wantErr: false, }, + // ─── Templated iPXE OS instance creation ────────────────────────── + { + name: "test Instance create with Templated iPXE OS, no override, should succeed", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceCreateRequest{ + Name: "test-instance-templated-ipxe", + TenantID: tn1.ID.String(), + InstanceTypeID: cdb.GetStrPtr(ist1.ID.String()), + VpcID: vpc1.ID.String(), + OperatingSystemID: cdb.GetStrPtr(osTemplated1.ID.String()), + Interfaces: []model.APIInterfaceCreateOrUpdateRequest{ + { + SubnetID: cdb.GetStrPtr(subnet1.ID.String()), + }, + }, + SSHKeyGroupIDs: []string{skg1.ID.String()}, + }, + reqMachine: nil, + reqOrg: tnOrg, + reqUser: tnu1, + respCode: http.StatusCreated, + respMessage: "", + }, + wantErr: false, + }, + { + name: "test Instance create with Templated iPXE OS, override allowed, user-data specified, should succeed", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceCreateRequest{ + Name: "test-instance-templated-ipxe-override", + TenantID: tn1.ID.String(), + InstanceTypeID: cdb.GetStrPtr(ist1.ID.String()), + VpcID: vpc1.ID.String(), + OperatingSystemID: cdb.GetStrPtr(osTemplated2.ID.String()), + UserData: cdb.GetStrPtr(cdmu.TestCommonCloudInit), + Interfaces: []model.APIInterfaceCreateOrUpdateRequest{ + { + SubnetID: cdb.GetStrPtr(subnet1.ID.String()), + }, + }, + SSHKeyGroupIDs: []string{skg1.ID.String()}, + }, + reqMachine: nil, + reqOrg: tnOrg, + reqUser: tnu1, + respCode: http.StatusCreated, + respMessage: "", + }, + wantErr: false, + }, + { + name: "test Instance create with Templated iPXE OS, iPXE script specified, should fail", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceCreateRequest{ + Name: "test-instance-templated-ipxe-with-script", + TenantID: tn1.ID.String(), + InstanceTypeID: cdb.GetStrPtr(ist1.ID.String()), + VpcID: vpc1.ID.String(), + OperatingSystemID: cdb.GetStrPtr(osTemplated1.ID.String()), + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + Interfaces: []model.APIInterfaceCreateOrUpdateRequest{ + { + SubnetID: cdb.GetStrPtr(subnet1.ID.String()), + }, + }, + }, + reqMachine: mc14, + reqOrg: tnOrg, + reqUser: tnu1, + respCode: http.StatusBadRequest, + respMessage: "cannot be specified with Operating System of type", + }, + wantErr: false, + }, + { + name: "test Instance create with deactivated Templated iPXE OS, should fail", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceCreateRequest{ + Name: "test-instance-templated-deactivated", + TenantID: tn1.ID.String(), + InstanceTypeID: cdb.GetStrPtr(ist1.ID.String()), + VpcID: vpc1.ID.String(), + OperatingSystemID: cdb.GetStrPtr(osTemplatedDeactivated.ID.String()), + Interfaces: []model.APIInterfaceCreateOrUpdateRequest{ + { + SubnetID: cdb.GetStrPtr(subnet1.ID.String()), + }, + }, + }, + reqMachine: mc14, + reqOrg: tnOrg, + reqUser: tnu1, + respCode: http.StatusBadRequest, + respMessage: "has been deactivated", + }, + wantErr: false, + }, + { + name: "test Instance create with Templated iPXE OS, AlwaysBootWithCustomIpxe, should fail", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceCreateRequest{ + Name: "test-instance-templated-always-boot", + TenantID: tn1.ID.String(), + InstanceTypeID: cdb.GetStrPtr(ist1.ID.String()), + VpcID: vpc1.ID.String(), + OperatingSystemID: cdb.GetStrPtr(osTemplated1.ID.String()), + AlwaysBootWithCustomIpxe: cdb.GetBoolPtr(true), + Interfaces: []model.APIInterfaceCreateOrUpdateRequest{ + { + SubnetID: cdb.GetStrPtr(subnet1.ID.String()), + }, + }, + }, + reqMachine: mc14, + reqOrg: tnOrg, + reqUser: tnu1, + respCode: http.StatusBadRequest, + respMessage: "cannot be set with Operating System of type", + }, + wantErr: false, + }, + // ─── Provider-owned OS instance creation ────────────────────────── + { + name: "test Instance create with provider-owned Templated iPXE OS, should succeed", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceCreateRequest{ + Name: "test-instance-provider-os", + TenantID: tn1.ID.String(), + InstanceTypeID: cdb.GetStrPtr(ist1.ID.String()), + VpcID: vpc1.ID.String(), + OperatingSystemID: cdb.GetStrPtr(osProviderTemplated.ID.String()), + Interfaces: []model.APIInterfaceCreateOrUpdateRequest{ + { + SubnetID: cdb.GetStrPtr(subnet1.ID.String()), + }, + }, + SSHKeyGroupIDs: []string{skg1.ID.String()}, + }, + reqMachine: nil, + reqOrg: tnOrg, + reqUser: tnu1, + respCode: http.StatusCreated, + respMessage: "", + }, + wantErr: false, + }, + { + name: "test Instance create with provider-owned OS, user-data override not allowed, should fail", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceCreateRequest{ + Name: "test-instance-provider-os-override", + TenantID: tn1.ID.String(), + InstanceTypeID: cdb.GetStrPtr(ist1.ID.String()), + VpcID: vpc1.ID.String(), + OperatingSystemID: cdb.GetStrPtr(osProviderTemplated.ID.String()), + UserData: cdb.GetStrPtr("custom user data"), + Interfaces: []model.APIInterfaceCreateOrUpdateRequest{ + { + SubnetID: cdb.GetStrPtr(subnet1.ID.String()), + }, + }, + }, + reqMachine: mc14, + reqOrg: tnOrg, + reqUser: tnu1, + respCode: http.StatusBadRequest, + respMessage: "does not allow overriding", + }, + wantErr: false, + }, { name: "test Instance create API endpoint failed, same InfiniBand interfaces specified multiple times in request", fields: fields{ @@ -4058,6 +4325,15 @@ func TestUpdateInstanceHandler_Handle(t *testing.T) { desd17 := common.TestBuildDpuExtensionServiceDeployment(t, dbSession, des1, inst17.ID, "1.0.0", cdbm.DpuExtensionServiceDeploymentStatusRunning, tnu1) assert.NotNil(t, desd17) + // Instance with no OperatingSystem (raw ipxeScript only) to test that an + // update setting a deactivated OS on an instance that previously had no OS + // is rejected. + mcNoOs := testInstanceBuildMachine(t, dbSession, ip.ID, st1.ID, cdb.GetBoolPtr(false), nil) + assert.NotNil(t, mcNoOs) + + instNoOs := testInstanceBuildInstance(t, dbSession, "test-instance-no-os", tn1.ID, ip.ID, st1.ID, &ist1.ID, vpc1.ID, cdb.GetStrPtr(mcNoOs.ID), nil, cdb.GetStrPtr(common.DefaultIpxeScript), cdbm.InstanceStatusReady) + assert.NotNil(t, instNoOs) + e := echo.New() cfg := common.GetTestConfig() tc := &tmocks.Client{} @@ -4917,6 +5193,27 @@ func TestUpdateInstanceHandler_Handle(t *testing.T) { }, wantErr: false, }, + { + name: "test Instance update API endpoint failure setting deactivated OS on instance with no prior OS", + fields: fields{ + dbSession: dbSession, + tc: tc, + scp: scp, + cfg: cfg, + }, + args: args{ + reqData: &model.APIInstanceUpdateRequest{ + OperatingSystemID: cdb.GetStrPtr(os3off.ID.String()), + }, + reqInstance: instNoOs.ID.String(), + cleanInstanceToStatus: instNoOs.Status, + reqOrg: tnOrg1, + reqUser: tnu1, + respCode: http.StatusBadRequest, + respMessage: cdb.GetStrPtr("has been deactivated"), + }, + wantErr: false, + }, { name: "test Instance update API endpoint failure when terminating", fields: fields{ diff --git a/api/pkg/api/handler/instancebatch.go b/api/pkg/api/handler/instancebatch.go index 4a1419167..872252cfd 100644 --- a/api/pkg/api/handler/instancebatch.go +++ b/api/pkg/api/handler/instancebatch.go @@ -80,7 +80,7 @@ func NewBatchCreateInstanceHandler(dbSession *cdb.Session, tc temporalClient.Cli // buildBatchInstanceCreateRequestOsConfig validates and retrieves OS configuration for batch instance creation. // This mirrors the behavior of CreateInstanceHandler.buildInstanceCreateRequestOsConfig. // Returns: osConfig, osID, and error (matching single API pattern) -func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c echo.Context, logger *zerolog.Logger, apiRequest *model.APIBatchInstanceCreateRequest, site *cdbm.Site) (*cwssaws.OperatingSystem, *uuid.UUID, *cutil.APIError) { +func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c echo.Context, logger *zerolog.Logger, apiRequest *model.APIBatchInstanceCreateRequest, site *cdbm.Site, tenant *cdbm.Tenant) (*cwssaws.InstanceOperatingSystemConfig, *uuid.UUID, *cutil.APIError) { ctx := c.Request().Context() @@ -92,10 +92,10 @@ func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "Failed to validate OperatingSystem data", err) } - return &cwssaws.OperatingSystem{ + return &cwssaws.InstanceOperatingSystemConfig{ RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, // Set by the earlier call to ValidateAndSetOperatingSystemData PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, // Set by the earlier call to ValidateAndSetOperatingSystemData - Variant: &cwssaws.OperatingSystem_Ipxe{ + Variant: &cwssaws.InstanceOperatingSystemConfig_Ipxe{ Ipxe: &cwssaws.InlineIpxe{ IpxeScript: *apiRequest.IpxeScript, }, @@ -138,8 +138,8 @@ func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c return c.Str("OperatingSystem ID", os.ID.String()) }) - // Confirm ownership between tenant and OS. - if os.TenantID.String() != apiRequest.TenantID { + // Tenant can use this OS if they created it or offered by Provider + if !(os.TenantID == nil || *os.TenantID == tenant.ID) { logger.Error().Msg("OperatingSystem in request is not owned by tenant") return nil, nil, cutil.NewAPIError(http.StatusBadRequest, "OperatingSystem specified in request is not owned by Tenant", nil) } @@ -190,28 +190,27 @@ func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c // Options below should all have been set by the // earlier call to ValidateAndSetOperatingSystemData - if os.Type == cdbm.OperatingSystemTypeIPXE { - return &cwssaws.OperatingSystem{ - RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.OperatingSystem_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *apiRequest.IpxeScript, - }, - }, - UserData: apiRequest.UserData, - }, osID, nil - } else { - return &cwssaws.OperatingSystem{ - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.OperatingSystem_OsImageId{ - OsImageId: &cwssaws.UUID{ - Value: os.ID.String(), - }, - }, - UserData: apiRequest.UserData, - }, osID, nil + result := cwssaws.InstanceOperatingSystemConfig{ + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + UserData: apiRequest.UserData, } + switch os.Type { + case cdbm.OperatingSystemTypeIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *apiRequest.IpxeScript}, + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + } + case cdbm.OperatingSystemTypeImage: + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OsImageId{ + OsImageId: &cwssaws.UUID{Value: os.ID.String()}, + } + } + return &result, osID, nil } // Handle godoc @@ -365,19 +364,18 @@ func (bcih BatchCreateInstanceHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) } - // Deprecated: tenantId in request body. Infer from org when not provided. - if apiRequest.TenantID == "" { - apiRequest.TenantID = tenant.ID.String() - } - - apiTenant, err := common.GetTenantFromIDString(ctx, nil, apiRequest.TenantID, bcih.dbSession) - if err != nil { - logger.Warn().Err(err).Msg("error retrieving tenant from request") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request is not valid", nil) - } - if apiTenant.ID != tenant.ID { - logger.Warn().Msg("tenant id in request does not match tenant in org") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request does not match tenant in org", nil) + // If the caller provided an explicit tenantId in the body, validate it matches the org. + // TODO: tenantId as parameter is deprecated and will need to be removed by 2026-10-01. + if apiRequest.TenantID != "" { + apiTenant, terr := common.GetTenantFromIDString(ctx, nil, apiRequest.TenantID, bcih.dbSession) + if terr != nil { + logger.Warn().Err(terr).Msg("error retrieving tenant from request") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request is not valid", nil) + } + if apiTenant.ID != tenant.ID { + logger.Warn().Msg("tenant id in request does not match tenant in org") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request does not match tenant in org", nil) + } } // Validate the instance type @@ -838,7 +836,7 @@ func (bcih BatchCreateInstanceHandler) Handle(c echo.Context) error { // apiRequest will be mutated for use in CreateFromParams. // osConfig will hold the struct/data for use with Temporal/Carbide calls. // Errors will be returned already in the form of cutil.NewAPIErrorResponse - osConfig, osID, oserr := bcih.buildBatchInstanceCreateRequestOsConfig(c, &logger, &apiRequest, site) + osConfig, osID, oserr := bcih.buildBatchInstanceCreateRequestOsConfig(c, &logger, &apiRequest, site, tenant) if oserr != nil { // buildBatchInstanceCreateRequestOsConfig already handles logging, // so this is a bit redundant, but this log brings you to the diff --git a/api/pkg/api/handler/ipxetemplate.go b/api/pkg/api/handler/ipxetemplate.go new file mode 100644 index 000000000..c43b74ae6 --- /dev/null +++ b/api/pkg/api/handler/ipxetemplate.go @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/NVIDIA/ncx-infra-controller-rest/api/internal/config" + "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/handler/util/common" + "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/model" + "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/pagination" + cerr "github.com/NVIDIA/ncx-infra-controller-rest/common/pkg/util" + sutil "github.com/NVIDIA/ncx-infra-controller-rest/common/pkg/util" + cdb "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" + cdbm "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/model" + cdbp "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/paginator" + mapset "github.com/deckarep/golang-set/v2" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/attribute" + tclient "go.temporal.io/sdk/client" +) + +// ~~~~~ GetAll Handler ~~~~~ // + +// GetAllIpxeTemplateHandler is the API Handler for getting all iPXE templates +type GetAllIpxeTemplateHandler struct { + dbSession *cdb.Session + tc tclient.Client + cfg *config.Config + tracerSpan *sutil.TracerSpan +} + +// NewGetAllIpxeTemplateHandler initializes and returns a new handler for getting all iPXE templates +func NewGetAllIpxeTemplateHandler(dbSession *cdb.Session, tc tclient.Client, cfg *config.Config) GetAllIpxeTemplateHandler { + return GetAllIpxeTemplateHandler{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + tracerSpan: sutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Get all iPXE templates +// @Description Get all iPXE templates propagated from bare-metal-manager-core. Templates are global (one row per stable core template UUID); per-site availability is recorded internally. The `siteId` query parameter is optional and may be repeated to restrict results to templates available at one or more sites. When omitted, a Provider Admin/Viewer receives templates available at any site owned by their infrastructure provider; a Tenant Admin receives templates available at any site whose provider the tenant has a Tenant Account on. +// @Tags iPXE Template +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param siteId query []string false "Optional site ID(s); may be repeated to restrict results to templates available at any of the sites" +// @Param pageNumber query integer false "Page number of results returned" +// @Param pageSize query integer false "Number of results per page" +// @Param orderBy query string false "Order by field" +// @Success 200 {object} []model.APIIpxeTemplate +// @Router /v2/org/{org}/carbide/ipxe-template [get] +func (h GetAllIpxeTemplateHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("GetAll", "IpxeTemplate", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate role (Provider Admin/Viewer or Tenant Admin) and org membership + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, h.dbSession, org, dbUser, true, false) + if apiError != nil { + return cerr.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) + } + + // Parse optional siteId query parameters. Multiple values (repeated + // `?siteId=...&siteId=...`) are supported. + requestedSiteIDStrs := c.QueryParams()["siteId"] + requestedSiteIDs := make([]uuid.UUID, 0, len(requestedSiteIDStrs)) + for _, s := range requestedSiteIDStrs { + if s == "" { + continue + } + parsed, perr := uuid.Parse(s) + if perr != nil { + return cerr.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid siteId in query parameter: %s", s), nil) + } + requestedSiteIDs = append(requestedSiteIDs, parsed) + } + + // Build the caller's authorized site set, tracking which sites come from the + // provider path vs the tenant path. A site can be in both sets for a + // dual-role caller — provider access wins (fewer restrictions). + // + // Note on tenant-path scoping: tenant access is established per-site via + // `TenantSite` associations (a tenant may be associated with some sites of + // a provider but not others). + providerSites := mapset.NewSet[uuid.UUID]() + tenantSites := mapset.NewSet[uuid.UUID]() + + if infrastructureProvider != nil { + siteDAO := cdbm.NewSiteDAO(h.dbSession) + sites, _, serr := siteDAO.GetAll(ctx, nil, + cdbm.SiteFilterInput{InfrastructureProviderIDs: []uuid.UUID{infrastructureProvider.ID}}, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites from DB") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve provider sites, DB error", nil) + } + for i := range sites { + providerSites.Add(sites[i].ID) + } + } + + if tenant != nil { + tsDAO := cdbm.NewTenantSiteDAO(h.dbSession) + tss, _, terr := tsDAO.GetAll(ctx, nil, + cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenant.ID}}, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations from DB") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Site associations, DB error", nil) + } + for i := range tss { + tenantSites.Add(tss[i].SiteID) + } + } + + isAuthorized := func(id uuid.UUID) bool { + return providerSites.Contains(id) || tenantSites.Contains(id) + } + + // Determine the effective site filter: + // - siteId(s) provided: must all be authorized; use them as-is. + // - siteId(s) omitted: use the union of provider and tenant accessible sites. + var effectiveSiteIDs []uuid.UUID + if len(requestedSiteIDs) > 0 { + for _, id := range requestedSiteIDs { + if !isAuthorized(id) { + logger.Warn().Str("siteID", id.String()).Msg("org not authorized to access requested Site") + return cerr.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Current org is not authorized to access Site: %s", id.String()), nil) + } + } + effectiveSiteIDs = requestedSiteIDs + } else { + effectiveSiteIDs = providerSites.Union(tenantSites).ToSlice() + } + + // No authorized sites — neither provider-owned nor reachable via a tenant account. + if len(effectiveSiteIDs) == 0 { + return cerr.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not associated with any Site", nil) + } + + // Validate pagination request + pageRequest := pagination.PageRequest{} + if err := c.Bind(&pageRequest); err != nil { + logger.Warn().Err(err).Msg("error binding pagination request data into API model") + return cerr.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request pagination data", nil) + } + if err := pageRequest.Validate(cdbm.IpxeTemplateOrderByFields); err != nil { + logger.Warn().Err(err).Msg("error validating pagination request data") + return cerr.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to validate pagination request data", err) + } + + // Resolve which template IDs are available at the authorized sites via + // the IpxeTemplateSiteAssociation table. + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(h.dbSession) + associations, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{SiteIDs: effectiveSiteIDs}, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations from DB") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE template site associations, DB error", nil) + } + + templateIDSet := mapset.NewSet[uuid.UUID]() + for _, a := range associations { + templateIDSet.Add(a.IpxeTemplateID) + } + templateIDs := templateIDSet.ToSlice() + + templateDAO := cdbm.NewIpxeTemplateDAO(h.dbSession) + templates, total, err := templateDAO.GetAll( + ctx, + nil, + cdbm.IpxeTemplateFilterInput{IDs: templateIDs}, + cdbp.PageInput{ + Offset: pageRequest.Offset, + Limit: pageRequest.Limit, + OrderBy: pageRequest.OrderBy, + }, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE templates from DB") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE templates, DB error", nil) + } + + apiTemplates := []*model.APIIpxeTemplate{} + for i := range templates { + apiTemplates = append(apiTemplates, model.NewAPIIpxeTemplate(&templates[i])) + } + + pageResponse := pagination.NewPageResponse(*pageRequest.PageNumber, *pageRequest.PageSize, total, pageRequest.OrderByStr) + pageHeader, err := json.Marshal(pageResponse) + if err != nil { + logger.Error().Err(err).Msg("error marshaling pagination response") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to generate pagination response header", nil) + } + c.Response().Header().Set(pagination.ResponseHeaderName, string(pageHeader)) + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, apiTemplates) +} + +// ~~~~~ Get Handler ~~~~~ // + +// GetIpxeTemplateHandler is the API Handler for retrieving a single iPXE template +type GetIpxeTemplateHandler struct { + dbSession *cdb.Session + tc tclient.Client + cfg *config.Config + tracerSpan *sutil.TracerSpan +} + +// NewGetIpxeTemplateHandler initializes and returns a new handler to retrieve an iPXE template +func NewGetIpxeTemplateHandler(dbSession *cdb.Session, tc tclient.Client, cfg *config.Config) GetIpxeTemplateHandler { + return GetIpxeTemplateHandler{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + tracerSpan: sutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Retrieve an iPXE template +// @Description Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is currently available (Provider Admin/Viewer for a Site owned by their infrastructure provider, or Tenant Admin with a Tenant Account on a Site reporting the template). +// @Tags iPXE Template +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param id path string true "Stable template ID (UUID from core)" +// @Success 200 {object} model.APIIpxeTemplate +// @Router /v2/org/{org}/carbide/ipxe-template/{id} [get] +func (h GetIpxeTemplateHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("Get", "IpxeTemplate", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate role (Provider Admin/Viewer or Tenant Admin) — this also validates + // org membership, so no separate membership check is needed here. + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, h.dbSession, org, dbUser, true, false) + if apiError != nil { + return cerr.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) + } + + // Parse template ID from URL (this is the stable core template UUID, which is + // also the primary key in REST). + templateIDStr := c.Param("id") + templateID, err := uuid.Parse(templateIDStr) + if err != nil { + return cerr.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid iPXE template ID: %s", templateIDStr), nil) + } + + logger = logger.With().Str("IpxeTemplate ID", templateIDStr).Logger() + h.tracerSpan.SetAttribute(handlerSpan, attribute.String("ipxe_template_id", templateIDStr), logger) + + templateDAO := cdbm.NewIpxeTemplateDAO(h.dbSession) + tmpl, err := templateDAO.Get(ctx, nil, templateID) + if err != nil { + if errors.Is(err, cdb.ErrDoesNotExist) { + return cerr.NewAPIErrorResponse(c, http.StatusNotFound, fmt.Sprintf("Could not find iPXE template with ID: %s", templateIDStr), nil) + } + logger.Error().Err(err).Msg("error retrieving iPXE template from DB") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE template, DB error", nil) + } + + // Authorization: caller must be associated (via provider ownership or tenant + // account) with at least one Site at which this template is currently + // reported. + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(h.dbSession) + associations, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{IpxeTemplateIDs: []uuid.UUID{templateID}}, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify iPXE template authorization, DB error", nil) + } + + if !callerHasAccessToAnyAssociatedSite(ctx, logger, h.dbSession, infrastructureProvider, tenant, associations) { + logger.Warn().Msg("caller is not authorized to access any Site associated with this iPXE template") + return cerr.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not authorized to access this iPXE template", nil) + } + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, model.NewAPIIpxeTemplate(tmpl)) +} + +// callerHasAccessToAnyAssociatedSite returns true when the caller (provider or tenant) +// has access to at least one site in the given association set. +func callerHasAccessToAnyAssociatedSite( + ctx context.Context, + logger zerolog.Logger, + dbSession *cdb.Session, + provider *cdbm.InfrastructureProvider, + tenant *cdbm.Tenant, + associations []cdbm.IpxeTemplateSiteAssociation, +) bool { + if len(associations) == 0 { + return false + } + + siteIDs := make([]uuid.UUID, 0, len(associations)) + for _, a := range associations { + siteIDs = append(siteIDs, a.SiteID) + } + + // Provider path: any site owned by the caller's provider. + if provider != nil { + siteDAO := cdbm.NewSiteDAO(dbSession) + sites, _, serr := siteDAO.GetAll(ctx, nil, cdbm.SiteFilterInput{ + InfrastructureProviderIDs: []uuid.UUID{provider.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: cdb.GetIntPtr(1)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites for iPXE template authorization") + return false + } + if len(sites) > 0 { + return true + } + } + + // Tenant path: any site reachable via a TenantSite association. + if tenant != nil { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, terr := tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: cdb.GetIntPtr(1)}, nil) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations for iPXE template authorization") + return false + } + if len(tss) > 0 { + return true + } + } + + return false +} diff --git a/api/pkg/api/handler/ipxetemplate_test.go b/api/pkg/api/handler/ipxetemplate_test.go new file mode 100644 index 000000000..bfe24e5fb --- /dev/null +++ b/api/pkg/api/handler/ipxetemplate_test.go @@ -0,0 +1,737 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/NVIDIA/ncx-infra-controller-rest/api/internal/config" + "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/model" + cdb "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" + cdbm "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/model" + cdbu "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/util" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/uptrace/bun/extra/bundebug" +) + +func testIpxeTemplateInitDB(t *testing.T) *cdb.Session { + dbSession := cdbu.GetTestDBSession(t, false) + dbSession.DB.AddQueryHook(bundebug.NewQueryHook( + bundebug.WithEnabled(false), + bundebug.FromEnv("BUNDEBUG"), + )) + return dbSession +} + +func testIpxeTemplateHandlerSetupSchema(t *testing.T, dbSession *cdb.Session) { + ctx := context.Background() + + // Reset parent tables before any table whose CREATE references them via + // foreign keys. Order: providers first, then sites/tenants, then the + // global ipxe_template table, then the association tables that reference + // ipxe_template/site/tenant. + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.InfrastructureProvider)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.Site)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.Tenant)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.IpxeTemplate)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.IpxeTemplateSiteAssociation)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.TenantSite)(nil))) +} + +type ipxeTemplateTestFixture struct { + ip *cdbm.InfrastructureProvider + site *cdbm.Site + tmpl1 *cdbm.IpxeTemplate + tmpl2 *cdbm.IpxeTemplate +} + +// associateTemplate creates an IpxeTemplateSiteAssociation row linking the +// global template to the given site. +func associateTemplate(t *testing.T, dbSession *cdb.Session, templateID, siteID uuid.UUID) { + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + _, err := itsaDAO.Create(context.Background(), nil, cdbm.IpxeTemplateSiteAssociationCreateInput{ + IpxeTemplateID: templateID, + SiteID: siteID, + }) + assert.Nil(t, err) +} + +func testIpxeTemplateSetupTestData(t *testing.T, dbSession *cdb.Session, org string) *ipxeTemplateTestFixture { + ctx := context.Background() + + ip := &cdbm.InfrastructureProvider{ + ID: uuid.New(), + Name: "test-provider", + Org: org, + } + _, err := dbSession.DB.NewInsert().Model(ip).Exec(ctx) + assert.Nil(t, err) + + site := &cdbm.Site{ + ID: uuid.New(), + Name: "test-site", + Org: org, + InfrastructureProviderID: ip.ID, + Status: cdbm.SiteStatusRegistered, + } + _, err = dbSession.DB.NewInsert().Model(site).Exec(ctx) + assert.Nil(t, err) + + dao := cdbm.NewIpxeTemplateDAO(dbSession) + + tmpl1, err := dao.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "kernel-initrd", Scope: cdbm.IpxeTemplateScopePublic, + RequiredParams: []string{"kernel_params"}, ReservedParams: []string{"base_url"}, RequiredArtifacts: []string{"kernel"}, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, tmpl1.ID, site.ID) + + tmpl2, err := dao.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "ubuntu-autoinstall", Scope: cdbm.IpxeTemplateScopePublic, + RequiredParams: []string{}, ReservedParams: []string{}, RequiredArtifacts: []string{"iso"}, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, tmpl2.ID, site.ID) + + return &ipxeTemplateTestFixture{ip: ip, site: site, tmpl1: tmpl1, tmpl2: tmpl2} +} + +func createIpxeTemplateMockUser(org string) *cdbm.User { + return &cdbm.User{ + StarfleetID: cdb.GetStrPtr("test-user"), + OrgData: cdbm.OrgData{ + org: cdbm.Org{ + ID: 123, + Name: org, + DisplayName: org, + OrgType: "ENTERPRISE", + Roles: []string{"FORGE_PROVIDER_VIEWER"}, + }, + }, + } +} + +func createIpxeTemplateTenantMockUser(org string) *cdbm.User { + return &cdbm.User{ + StarfleetID: cdb.GetStrPtr("test-tenant-user"), + OrgData: cdbm.OrgData{ + org: cdbm.Org{ + ID: 456, + Name: org, + DisplayName: org, + OrgType: "ENTERPRISE", + Roles: []string{"FORGE_TENANT_ADMIN"}, + }, + }, + } +} + +func createIpxeTemplateMixedRoleMockUser(org string) *cdbm.User { + return &cdbm.User{ + StarfleetID: cdb.GetStrPtr("test-mixed-user"), + OrgData: cdbm.OrgData{ + org: cdbm.Org{ + ID: 789, + Name: org, + DisplayName: org, + OrgType: "ENTERPRISE", + Roles: []string{"FORGE_PROVIDER_VIEWER", "FORGE_TENANT_ADMIN"}, + }, + }, + } +} + +func TestGetAllIpxeTemplateHandler_Handle(t *testing.T) { + e := echo.New() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + + testIpxeTemplateHandlerSetupSchema(t, dbSession) + + ctx := context.Background() + cfg := &config.Config{} + handler := NewGetAllIpxeTemplateHandler(dbSession, nil, cfg) + + org := "test-org" + fix := testIpxeTemplateSetupTestData(t, dbSession, org) + + // Unmanaged site in a different org + unmanagedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "unmanaged-provider", Org: "other-org"} + _, err := dbSession.DB.NewInsert().Model(unmanagedIP).Exec(ctx) + assert.Nil(t, err) + + unmanagedSite := &cdbm.Site{ID: uuid.New(), Name: "unmanaged-site", Org: "other-org", InfrastructureProviderID: unmanagedIP.ID, Status: cdbm.SiteStatusRegistered} + _, err = dbSession.DB.NewInsert().Model(unmanagedSite).Exec(ctx) + assert.Nil(t, err) + + // Second provider-owned site with its own template, to exercise the + // "omitted siteId", multi-siteId, and per-site tenant-association paths. + site2 := &cdbm.Site{ID: uuid.New(), Name: "test-site-2", Org: org, InfrastructureProviderID: fix.ip.ID, Status: cdbm.SiteStatusRegistered} + _, err = dbSession.DB.NewInsert().Model(site2).Exec(ctx) + assert.Nil(t, err) + + tmpl3, err := cdbm.NewIpxeTemplateDAO(dbSession).Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "site2-public", Scope: cdbm.IpxeTemplateScopePublic, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, tmpl3.ID, site2.ID) + + // Tenant with a TenantSite association to fix.site only (not site2). + tenantOrg := "test-tenant-org" + tenantWithCapability := &cdbm.Tenant{ID: uuid.New(), Name: "test-tenant", Org: tenantOrg, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithCapability).Exec(ctx) + assert.Nil(t, err) + + tenantSite := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantWithCapability.ID, TenantOrg: tenantOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSite).Exec(ctx) + assert.Nil(t, err) + + // Non-privileged tenant WITH a TenantSite association — should still succeed. + tenantOrgNonPriv := "test-tenant-non-priv" + tenantNonPriv := &cdbm.Tenant{ID: uuid.New(), Name: "non-priv-tenant", Org: tenantOrgNonPriv, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantNonPriv).Exec(ctx) + assert.Nil(t, err) + + tenantSiteNonPriv := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantNonPriv.ID, TenantOrg: tenantOrgNonPriv, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteNonPriv).Exec(ctx) + assert.Nil(t, err) + + // Tenant with TenantSite to fix.site AND site2. + tenantOrgTwoSites := "test-tenant-two-sites" + tenantTwoSites := &cdbm.Tenant{ID: uuid.New(), Name: "two-sites-tenant", Org: tenantOrgTwoSites, Config: &cdbm.TenantConfig{}} + _, err = dbSession.DB.NewInsert().Model(tenantTwoSites).Exec(ctx) + assert.Nil(t, err) + + tenantSiteTwoA := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantTwoSites.ID, TenantOrg: tenantOrgTwoSites, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteTwoA).Exec(ctx) + assert.Nil(t, err) + + tenantSiteTwoB := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantTwoSites.ID, TenantOrg: tenantOrgTwoSites, SiteID: site2.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteTwoB).Exec(ctx) + assert.Nil(t, err) + + // Tenant without any TenantSite association. + tenantOrgNoCapability := "test-tenant-no-capability" + tenantWithoutCapability := &cdbm.Tenant{ID: uuid.New(), Name: "no-cap-tenant", Org: tenantOrgNoCapability, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutCapability).Exec(ctx) + assert.Nil(t, err) + + tenantOrgNoAccount := "test-tenant-no-site" + tenantWithoutAccount := &cdbm.Tenant{ID: uuid.New(), Name: "no-site-tenant", Org: tenantOrgNoAccount, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutAccount).Exec(ctx) + assert.Nil(t, err) + + // Mixed-role org. + mixedOrg := "mixed-role-org" + mixedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "mixed-provider", Org: mixedOrg} + _, err = dbSession.DB.NewInsert().Model(mixedIP).Exec(ctx) + assert.Nil(t, err) + + mixedTenant := &cdbm.Tenant{ID: uuid.New(), Name: "mixed-tenant", Org: mixedOrg, Config: &cdbm.TenantConfig{}} + _, err = dbSession.DB.NewInsert().Model(mixedTenant).Exec(ctx) + assert.Nil(t, err) + + mixedTenantSite := &cdbm.TenantSite{ID: uuid.New(), TenantID: mixedTenant.ID, TenantOrg: mixedOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(mixedTenantSite).Exec(ctx) + assert.Nil(t, err) + + _ = fix.tmpl1 + _ = fix.tmpl2 + _ = tmpl3 + _ = tenantSite + _ = tenantSiteNonPriv + _ = tenantWithoutCapability + _ = tenantWithoutAccount + + tests := []struct { + name string + siteIDs []string + setupContext func(c echo.Context) + expectedStatus int + checkResponseContent func(t *testing.T, body []byte) + }{ + { + name: "omitted siteId returns all templates available at provider's sites", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + // tmpl1, tmpl2 on fix.site + tmpl3 on site2. + assert.Len(t, response, 3) + }, + }, + { + name: "omitted siteId for tenant returns templates for tenant-accessible sites", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrg) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + // tenantOrg has a TenantSite on fix.site only, so only tmpl1 and + // tmpl2 are visible. + assert.Len(t, response, 2) + }, + }, + { + name: "multiple siteIds filters by the union", + siteIDs: []string{fix.site.ID.String(), site2.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 3) + }, + }, + { + name: "multiple siteIds with one unauthorized returns 403", + siteIDs: []string{fix.site.ID.String(), unmanagedSite.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "invalid siteId returns 400", + siteIDs: []string{"not-a-uuid"}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "successful GetAll with valid siteId", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + ids := map[string]bool{} + for _, tmpl := range response { + ids[tmpl.ID] = true + } + assert.True(t, ids[fix.tmpl1.ID.String()]) + assert.True(t, ids[fix.tmpl2.ID.String()]) + }, + }, + { + name: "cannot retrieve from unmanaged site", + siteIDs: []string{unmanagedSite.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "missing user context returns 500", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "tenant with TenantSite can retrieve templates for that site", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrg) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + }, + }, + { + name: "non-privileged tenant with TenantSite can retrieve templates", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNonPriv)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgNonPriv) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + }, + }, + { + name: "tenant without TenantSite is denied", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoCapability)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgNoCapability) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "tenant without TenantSite cannot access provider site", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoAccount)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgNoAccount) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "mixed-role user fails provider check but passes tenant authorization", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMixedRoleMockUser(mixedOrg)) + c.SetParamNames("orgName") + c.SetParamValues(mixedOrg) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + }, + }, + { + name: "tenant associated with one site cannot access sibling site on same provider", + siteIDs: []string{site2.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrg) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "tenant with TenantSite on multiple sites sees templates on each", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgTwoSites)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgTwoSites) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + // tmpl1 and tmpl2 on fix.site + tmpl3 on site2 = 3 templates. + assert.Len(t, response, 3) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/v2/org/" + org + "/carbide/ipxe-template" + params := []string{} + for _, sid := range tt.siteIDs { + params = append(params, "siteId="+sid) + } + if len(params) > 0 { + url += "?" + params[0] + for _, p := range params[1:] { + url += "&" + p + } + } + + req := httptest.NewRequest(http.MethodGet, url, nil) + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + tt.setupContext(c) + + err := handler.Handle(c) + assert.Nil(t, err) + assert.Equal(t, tt.expectedStatus, rec.Code) + if tt.expectedStatus != rec.Code { + t.Errorf("Response: %v", rec.Body.String()) + } + if tt.checkResponseContent != nil && rec.Code == http.StatusOK { + tt.checkResponseContent(t, rec.Body.Bytes()) + } + }) + } +} + +func TestGetIpxeTemplateHandler_Handle(t *testing.T) { + e := echo.New() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + + testIpxeTemplateHandlerSetupSchema(t, dbSession) + + ctx := context.Background() + cfg := &config.Config{} + handler := NewGetIpxeTemplateHandler(dbSession, nil, cfg) + + org := "test-org" + fix := testIpxeTemplateSetupTestData(t, dbSession, org) + + // Unmanaged site in a different org + unmanagedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "unmanaged-provider-get", Org: "other-org"} + _, err := dbSession.DB.NewInsert().Model(unmanagedIP).Exec(ctx) + assert.Nil(t, err) + + unmanagedSite := &cdbm.Site{ID: uuid.New(), Name: "unmanaged-site-get", Org: "other-org", InfrastructureProviderID: unmanagedIP.ID, Status: cdbm.SiteStatusRegistered} + _, err = dbSession.DB.NewInsert().Model(unmanagedSite).Exec(ctx) + assert.Nil(t, err) + + unmanagedTmpl, err := cdbm.NewIpxeTemplateDAO(dbSession).Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "unmanaged-tmpl", Scope: cdbm.IpxeTemplateScopePublic, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, unmanagedTmpl.ID, unmanagedSite.ID) + + // Tenant with a TenantSite association to fix.site. + tenantOrg := "test-tenant-org" + tenantWithCapability := &cdbm.Tenant{ID: uuid.New(), Name: "test-tenant", Org: tenantOrg, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithCapability).Exec(ctx) + assert.Nil(t, err) + + tenantSiteGet := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantWithCapability.ID, TenantOrg: tenantOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteGet).Exec(ctx) + assert.Nil(t, err) + + // Non-privileged tenant WITH a TenantSite — should succeed. + tenantOrgNonPrivGet := "test-tenant-non-priv-get" + tenantNonPrivGet := &cdbm.Tenant{ID: uuid.New(), Name: "non-priv-tenant-get", Org: tenantOrgNonPrivGet, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantNonPrivGet).Exec(ctx) + assert.Nil(t, err) + + tenantSiteNonPrivGet := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantNonPrivGet.ID, TenantOrg: tenantOrgNonPrivGet, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteNonPrivGet).Exec(ctx) + assert.Nil(t, err) + + // Tenant without any TenantSite (no site access). + tenantOrgNoCapability := "test-tenant-no-capability-get" + tenantWithoutCapability := &cdbm.Tenant{ID: uuid.New(), Name: "no-cap-tenant-get", Org: tenantOrgNoCapability, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutCapability).Exec(ctx) + assert.Nil(t, err) + + tenantOrgNoAccount := "test-tenant-no-site-get" + tenantWithoutAccount := &cdbm.Tenant{ID: uuid.New(), Name: "no-site-tenant-get", Org: tenantOrgNoAccount, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutAccount).Exec(ctx) + assert.Nil(t, err) + + // Mixed-role org: provider check fails (site belongs to fix.ip), tenant path + // succeeds via TenantSite association. + mixedOrg := "mixed-role-org-get" + mixedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "mixed-provider-get", Org: mixedOrg} + _, err = dbSession.DB.NewInsert().Model(mixedIP).Exec(ctx) + assert.Nil(t, err) + + mixedTenant := &cdbm.Tenant{ID: uuid.New(), Name: "mixed-tenant-get", Org: mixedOrg, Config: &cdbm.TenantConfig{}} + _, err = dbSession.DB.NewInsert().Model(mixedTenant).Exec(ctx) + assert.Nil(t, err) + + mixedTenantSiteGet := &cdbm.TenantSite{ID: uuid.New(), TenantID: mixedTenant.ID, TenantOrg: mixedOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(mixedTenantSiteGet).Exec(ctx) + assert.Nil(t, err) + + _ = tenantSiteGet + _ = tenantSiteNonPrivGet + _ = tenantWithoutCapability + _ = tenantWithoutAccount + + tests := []struct { + name string + templateID string + setupContext func(c echo.Context) + expectedStatus int + checkResponseContent func(t *testing.T, body []byte) + }{ + { + name: "successful retrieval", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + assert.Equal(t, "kernel-initrd", response.Name) + }, + }, + { + name: "template not found", + templateID: uuid.New().String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, uuid.New().String()) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid uuid returns bad request", + templateID: "not-a-uuid", + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, "not-a-uuid") + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "cannot retrieve from unmanaged site", + templateID: unmanagedTmpl.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, unmanagedTmpl.ID.String()) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "missing user context returns 500", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.SetParamNames("orgName", "id") + c.SetParamValues(org, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "tenant with TenantSite can retrieve template", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrg, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + }, + }, + { + name: "non-privileged tenant with TenantSite can retrieve template", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNonPrivGet)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrgNonPrivGet, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + }, + }, + { + name: "tenant without TenantSite is denied", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoCapability)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrgNoCapability, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "tenant without TenantSite on requested site is denied", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoAccount)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrgNoAccount, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "mixed-role user fails provider check but passes tenant authorization", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMixedRoleMockUser(mixedOrg)) + c.SetParamNames("orgName", "id") + c.SetParamValues(mixedOrg, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/v2/org/" + org + "/carbide/ipxe-template/" + tt.templateID + req := httptest.NewRequest(http.MethodGet, url, nil) + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + tt.setupContext(c) + + err := handler.Handle(c) + assert.Nil(t, err) + assert.Equal(t, tt.expectedStatus, rec.Code) + if tt.expectedStatus != rec.Code { + t.Errorf("Response: %v", rec.Body.String()) + } + if tt.checkResponseContent != nil && rec.Code == http.StatusOK { + tt.checkResponseContent(t, rec.Body.Bytes()) + } + }) + } +} diff --git a/api/pkg/api/handler/operatingsystem.go b/api/pkg/api/handler/operatingsystem.go index ab6c4081c..a2392f6bc 100644 --- a/api/pkg/api/handler/operatingsystem.go +++ b/api/pkg/api/handler/operatingsystem.go @@ -38,7 +38,6 @@ import ( "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/model" "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/pagination" sc "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/client/site" - auth "github.com/NVIDIA/ncx-infra-controller-rest/auth/pkg/authorization" cutil "github.com/NVIDIA/ncx-infra-controller-rest/common/pkg/util" "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" cdb "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" @@ -47,6 +46,7 @@ import ( cdbp "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/paginator" swe "github.com/NVIDIA/ncx-infra-controller-rest/site-workflow/pkg/error" "github.com/NVIDIA/ncx-infra-controller-rest/workflow/pkg/queue" + osWorkflow "github.com/NVIDIA/ncx-infra-controller-rest/workflow/pkg/workflow/operatingsystem" cwssaws "github.com/NVIDIA/ncx-infra-controller-rest/workflow-schema/schema/site-agent/workflows/v1" ) @@ -93,49 +93,35 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, csh.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } - // Validate role, only Tenant Admins are allowed to create OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) - } - - // Validate request - // Bind request data to API model + // Bind request data to API model before OS-type check so we can inspect the OS type. apiRequest := model.APIOperatingSystemCreateRequest{} - err = c.Bind(&apiRequest) + err := c.Bind(&apiRequest) if err != nil { logger.Warn().Err(err).Msg("error binding request data into API model") return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request data, potentially invalid structure", nil) } - // Validate the tenant for which this OperatingSystem is being created - tenant, err := common.GetTenantForOrg(ctx, nil, csh.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) - } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) + // Infer type of OS from provided parameters: + osType := apiRequest.GetOperatingSystemType() + + // Image-based OS creation is not supported via this handler; Image OS + // definitions originate from carbide-core inventory synchronization. + if osType == cdbm.OperatingSystemTypeImage { + logger.Warn().Msg("attempted to create Image based Operating System via API") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Creation of Image based Operating Systems is no longer supported. Check your parameters and use ipxeScript or ipxeTemplateId.", nil) } - // Default TenantID to org's Tenant when nil; validate when set - if apiRequest.TenantID == nil { - apiRequest.TenantID = cdb.GetStrPtr(tenant.ID.String()) - } else if *apiRequest.TenantID != tenant.ID.String() { - logger.Warn().Str("tenantId", *apiRequest.TenantID).Msg("TenantID in request does not match org's Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID specified in request does not match org's Tenant", nil) + // Provider Admin is limited to iPXE Template-based OSes. When both roles + // allow the action, Provider Admin takes priority (provider-owned OS). + allowedByProvider := ip != nil && osType == cdbm.OperatingSystemTypeTemplatedIPXE + allowedByTenant := tenant != nil + if !allowedByProvider && !allowedByTenant { + logger.Warn().Msg("provider admin attempted to create non-template OS without tenant admin role") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only create iPXE Template-based Operating Systems", nil) } // Validate request attributes @@ -152,36 +138,40 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating user data in Operating System creation request", verr) } - // check for name uniqueness for the tenant, ie, tenant cannot have another os with same name - // TODO consider doing this with an advisory lock for correctness + // If the caller provided an explicit tenantId in the body, validate it matches the org. + // TODO: tenantId as parameter is deprecated and will need to be removed by 2026-10-01. + if tenant != nil && apiRequest.TenantID != nil { + apiTenant, terr := common.GetTenantFromIDString(ctx, nil, *apiRequest.TenantID, csh.dbSession) + if terr != nil { + logger.Warn().Err(terr).Msg("error retrieving tenant from request") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request is not valid", nil) + } + if apiTenant.ID != tenant.ID { + logger.Warn().Msg("tenant id in request does not match tenant in org") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request does not match tenant in org", nil) + } + } + + // Check for name uniqueness within the owner's scope. osDAO := cdbm.NewOperatingSystemDAO(csh.dbSession) - oss, tot, err := osDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - Names: []string{apiRequest.Name}, - }, - cdbp.PageInput{}, - nil, - ) + uniquenessFilter := cdbm.OperatingSystemFilterInput{Names: []string{apiRequest.Name}} + if allowedByProvider { + uniquenessFilter.InfrastructureProviderID = &ip.ID + } else { + uniquenessFilter.TenantIDs = []uuid.UUID{tenant.ID} + } + oss, tot, err := osDAO.GetAll(ctx, nil, uniquenessFilter, cdbp.PageInput{}, nil) if err != nil { - logger.Error().Err(err).Msg("db error checking for name uniqueness of tenant os") + logger.Error().Err(err).Msg("db error checking for name uniqueness of os") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create OperatingSystem due to DB error", nil) } if tot > 0 { - logger.Warn().Str("tenantId", tenant.ID.String()).Str("name", apiRequest.Name).Msg("Operating System with same name already exists for tenant") - return cutil.NewAPIErrorResponse(c, http.StatusConflict, "Another Operating System with specified name already exists for Tenant", validation.Errors{ + logger.Warn().Str("name", apiRequest.Name).Msg("Operating System with same name already exists") + return cutil.NewAPIErrorResponse(c, http.StatusConflict, fmt.Sprintf("Operating System: %s with specified name already exists", oss[0].ID.String()), validation.Errors{ "id": errors.New(oss[0].ID.String()), }) } - // check OS type from request - osType := cdbm.OperatingSystemTypeImage - if apiRequest.IpxeScript != nil { - osType = cdbm.OperatingSystemTypeIPXE - } - // Set the phoneHomeEnabled if provided in request phoneHomeEnabled := false if apiRequest.PhoneHomeEnabled != nil { @@ -199,97 +189,155 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { txCommitted := false defer common.RollbackTx(ctx, tx, &txCommitted) - // Verify or validate site - tsDAO := cdbm.NewTenantSiteDAO(csh.dbSession) - rdbst := []cdbm.Site{} - sttsmap := map[uuid.UUID]*cdbm.TenantSite{} + // Determine the effective scope before site-association logic: + // - Raw iPXE: always Global. validateRawIpxeOS accepts only nil + // or "Global"; the handler then forces it to Global so + // downstream logic can treat it uniformly. + // - Templated iPXE: scope is provided by the caller (Global or Limited). + osScope := apiRequest.Scope + if osType == cdbm.OperatingSystemTypeIPXE { + osScope = cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal) + } + + // Resolve target sites for the Operating System. + // - Global scope: auto-discover all registered sites for the owner (provider or tenant). + // - Limited scope: use explicitly requested siteIds, validated for existence and ownership. + // Note: scope "Local" is rejected at validation — Local OS are only created in carbide-core. dbossd := []cdbm.StatusDetail{} + sttsmap := map[uuid.UUID]*cdbm.TenantSite{} - // Get all TenantSite records for the Tenant - tss, _, err := tsDAO.GetAll( - ctx, - tx, - cdbm.TenantSiteFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - }, - cdbp.PageInput{ - Limit: cdb.GetIntPtr(cdbp.TotalLimit), - }, - nil, - ) - if err != nil { - logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) - } - for _, ts := range tss { - cts := ts - sttsmap[ts.SiteID] = &cts - } + isGlobal := osScope != nil && *osScope == cdbm.OperatingSystemScopeGlobal + isLimited := osScope != nil && *osScope == cdbm.OperatingSystemScopeLimited - // Validate the site for which this image based Operating System is being created - for _, stID := range apiRequest.SiteIDs { - site, serr := common.GetSiteFromIDString(ctx, nil, stID, csh.dbSession) - if serr != nil { - if serr == common.ErrInvalidID { + stDAO := cdbm.NewSiteDAO(csh.dbSession) + var targetSites []cdbm.Site + siteFilter := cdbm.SiteFilterInput{} + runSiteQuery := false + + if isLimited { + // Limited-scope iPXE: resolve the explicitly requested site IDs. + if ip == nil { + ip, err = common.GetInfrastructureProviderForOrg(ctx, nil, csh.dbSession, org) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Infrastructure Provider for org") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Infrastructure Provider for org", nil) + } + } + + requestedSiteIDs := make([]uuid.UUID, 0, len(apiRequest.SiteIDs)) + for _, stID := range apiRequest.SiteIDs { + parsed, perr := uuid.Parse(stID) + if perr != nil { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, invalid Site ID: %s", stID), nil) } - if serr == cdb.ErrDoesNotExist { - return cutil.NewAPIErrorResponse(c, http.StatusNotFound, fmt.Sprintf("Failed to create Operating System, could not find Site with ID: %s ", stID), nil) + requestedSiteIDs = append(requestedSiteIDs, parsed) + } + siteFilter.SiteIDs = requestedSiteIDs + runSiteQuery = len(requestedSiteIDs) > 0 + } else if isGlobal { + // Global scope: auto-discover all registered sites for the owner. + siteFilter.Statuses = []string{cdbm.SiteStatusRegistered} + if allowedByProvider { + siteFilter.InfrastructureProviderIDs = []uuid.UUID{ip.ID} + runSiteQuery = true + } else { + // Tenant Global (raw iPXE): restrict to sites accessible to the tenant. + tenantSiteIDs, tserr := getTenantSiteIDs(ctx, csh.dbSession, tenant.ID) + if tserr != nil { + logger.Error().Err(tserr).Msg("error retrieving tenant site IDs for global-scope raw iPXE OS") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant sites, DB error", nil) + } + if len(tenantSiteIDs) > 0 { + siteFilter.SiteIDs = tenantSiteIDs + runSiteQuery = true } - logger.Warn().Err(serr).Str("Site ID", stID).Msg("error retrieving Site from DB by ID") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, could not retrieve Site with ID: %s, DB error", stID), nil) } + } - if site.Status != cdbm.SiteStatusRegistered { - logger.Warn().Msg(fmt.Sprintf("Unable to associate Operating System to Site: %s. Site is not in Registered state", site.ID.String())) - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, Site: %s specified in request is not in Registered state", site.ID.String()), nil) + if runSiteQuery { + sites, _, sterr := stDAO.GetAll( + ctx, nil, + siteFilter, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if sterr != nil { + logger.Error().Err(sterr).Msg("error retrieving sites for Operating System") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve sites, DB error", nil) } + targetSites = sites + } - // Validate the TenantSite exists for current tenant and this site - _, ok := sttsmap[site.ID] - if !ok { - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Unable to associate Operating System with Site: %s, Tenant does not have access to Site", stID), nil) + // For Limited scope, ensure every requested site was found. + if isLimited && len(targetSites) != len(apiRequest.SiteIDs) { + found := make(map[uuid.UUID]struct{}, len(targetSites)) + for i := range targetSites { + found[targetSites[i].ID] = struct{}{} } - - // Validate the Site has the ImageBasedOperatingSystem capability enabled for Image based Operating Systems - if osType == cdbm.OperatingSystemTypeImage && (site.Config == nil || !site.Config.ImageBasedOperatingSystem) { - logger.Warn().Str("siteId", stID).Msg("Image based Operating System is not supported for Site, ImageBasedOperatingSystem capability is not enabled") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Creation of Image based Operating Systems is not supported. Site must have ImageBasedOperatingSystem capability enabled.", nil) + for _, stID := range apiRequest.SiteIDs { + parsed, _ := uuid.Parse(stID) + if _, ok := found[parsed]; !ok { + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, fmt.Sprintf("Failed to create Operating System, could not find Site with ID: %s ", stID), nil) + } } + } - rdbst = append(rdbst, *site) + // Validate all target sites: must be in Registered state and, for Limited scope, + // must belong to the caller's infrastructure provider (if set). + for i := range targetSites { + st := &targetSites[i] + if st.Status != cdbm.SiteStatusRegistered { + logger.Warn().Str("siteID", st.ID.String()).Msg("Unable to associate Operating System to Site: Site is not in Registered state") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, Site: %s is not in Registered state", st.ID.String()), nil) + } + if isLimited && ip != nil && st.InfrastructureProviderID != ip.ID { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Unable to associate Operating System with Site: %s, Site does not belong to provider", st.ID.String()), nil) + } } - // Create status based on OS type - osStatus := cdbm.OperatingSystemStatusReady - osStatusMessage := "Operating System is ready for use" - if osType == cdbm.OperatingSystemTypeImage { - osStatus = cdbm.OperatingSystemStatusSyncing - osStatusMessage = "received Operating System creation request, syncing" + // Create status: starts as Syncing since the definition is pushed to sites + // asynchronously via the SynchronizeOperatingSystem workflow. + osStatus := cdbm.OperatingSystemStatusSyncing + osStatusMessage := "received Operating System creation request, syncing" + + // Assign ownership: provider-owned OSes carry InfrastructureProviderID (tenant_id=nil); + // tenant-owned OSes carry TenantID (infrastructure_provider_id=nil). + // This aligns with the sync model where OSes from carbide-core are provider-owned. + var ownerTenantID *uuid.UUID + var ownerProviderID *uuid.UUID + if allowedByProvider { + ownerProviderID = &ip.ID + } else { + ownerTenantID = &tenant.ID } // Create the db record for Operating System osInput := cdbm.OperatingSystemCreateInput{ - Name: apiRequest.Name, - Description: apiRequest.Description, - Org: org, - TenantID: &tenant.ID, - OsType: osType, - ImageURL: apiRequest.ImageURL, - ImageSHA: apiRequest.ImageSHA, - ImageAuthType: apiRequest.ImageAuthType, - ImageAuthToken: apiRequest.ImageAuthToken, - ImageDisk: apiRequest.ImageDisk, - RootFsId: apiRequest.RootFsID, - RootFsLabel: apiRequest.RootFsLabel, - IpxeScript: apiRequest.IpxeScript, - UserData: apiRequest.UserData, - IsCloudInit: apiRequest.IsCloudInit, - AllowOverride: apiRequest.AllowOverride, - EnableBlockStorage: apiRequest.EnableBlockStorage, - PhoneHomeEnabled: phoneHomeEnabled, - Status: osStatus, - CreatedBy: dbUser.ID, + Name: apiRequest.Name, + Description: apiRequest.Description, + Org: org, + TenantID: ownerTenantID, + InfrastructureProviderID: ownerProviderID, + OsType: osType, + ImageURL: apiRequest.ImageURL, + ImageSHA: apiRequest.ImageSHA, + ImageAuthType: apiRequest.ImageAuthType, + ImageAuthToken: apiRequest.ImageAuthToken, + ImageDisk: apiRequest.ImageDisk, + RootFsId: apiRequest.RootFsID, + RootFsLabel: apiRequest.RootFsLabel, + IpxeScript: apiRequest.IpxeScript, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + IpxeOsScope: osScope, + UserData: apiRequest.UserData, + IsCloudInit: apiRequest.IsCloudInit, + AllowOverride: apiRequest.AllowOverride, + EnableBlockStorage: apiRequest.EnableBlockStorage, + PhoneHomeEnabled: phoneHomeEnabled, + Status: osStatus, + CreatedBy: dbUser.ID, } os, err := osDAO.Create(ctx, tx, osInput) if err != nil { @@ -314,7 +362,7 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { // Create Operating System Site Associations ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(csh.dbSession) - for _, st := range rdbst { + for _, st := range targetSites { // Create Operating System Site Association ossa, serr := ossaDAO.Create( ctx, @@ -364,90 +412,28 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) } - // Trigger workflows to sync Image based Operating System with various Sites - for _, ossa := range dbossa { - // Get the temporal client for the site we are working with. - stc, err := csh.scp.GetClientByID(ossa.SiteID) - if err != nil { - logger.Error().Err(err).Msg("failed to retrieve Temporal client for Site") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve client for Site", nil) - } - - createOsRequest := &cwssaws.OsImageAttributes{ - Id: &cwssaws.UUID{Value: common.GetSiteOperatingSystemtID(os).String()}, - Name: &os.Name, - TenantOrganizationId: tenant.Org, - Description: os.Description, - SourceUrl: *os.ImageURL, - Digest: *os.ImageSHA, - CreateVolume: os.EnableBlockStorage, - AuthType: os.ImageAuthType, - AuthToken: os.ImageAuthToken, - RootfsId: os.RootFsID, - RootfsLabel: os.RootFsLabel, - } - - workflowOptions := temporalClient.StartWorkflowOptions{ - ID: "image-os-create-" + ossa.SiteID.String() + "-" + os.ID.String() + "-" + *ossa.Version, - WorkflowExecutionTimeout: cutil.WorkflowExecutionTimeout, - TaskQueue: queue.SiteTaskQueue, - } - - logger.Info().Str("Site ID", ossa.SiteID.String()).Msg("triggering Image based Operating System create workflow ") - - // Add context deadlines - ctx, cancel := context.WithTimeout(ctx, cutil.WorkflowContextTimeout) - defer cancel() - - // Trigger Site workflow - we, err := stc.ExecuteWorkflow(ctx, workflowOptions, "CreateOsImage", createOsRequest) - - if err != nil { - logger.Error().Err(err).Msg("failed to synchronously start Temporal workflow to create Operating System") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed start sync workflow to create Operating System on Site: %s", err), nil) - } - - wid := we.GetID() - logger.Info().Str("Workflow ID", wid).Msg("executed synchronous create Operating System workflow") - - // Block until the workflow has completed and returned success/error. - err = we.Get(ctx, nil) - if err != nil { - var timeoutErr *tp.TimeoutError - if errors.As(err, &timeoutErr) || err == context.DeadlineExceeded || ctx.Err() != nil { - - logger.Error().Err(err).Msg("failed to create Operating System, timeout occurred executing workflow on Site.") - - // Create a new context deadlines - newctx, newcancel := context.WithTimeout(context.Background(), cutil.WorkflowContextNewAfterTimeout) - defer newcancel() - - // Initiate termination workflow - serr := stc.TerminateWorkflow(newctx, wid, "", "timeout occurred executing create Operating System workflow") - if serr != nil { - logger.Error().Err(serr).Msg("failed to execute terminate Temporal workflow for creating Operating System") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to terminate synchronous Operating System creation workflow after timeout, Cloud and Site data may be de-synced: %s", serr), nil) - } - - logger.Info().Str("Workflow ID", wid).Msg("initiated terminate synchronous create Operating System workflow successfully") - - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to create Operating System, timeout occurred executing workflow on Site: %s", err), nil) - } + targetSiteIDs := make([]uuid.UUID, len(targetSites)) + for i, site := range targetSites { + targetSiteIDs[i] = site.ID + } - code, err := common.UnwrapWorkflowError(err) - logger.Error().Err(err).Msg("failed to synchronously execute Temporal workflow to create Operating System") - return cutil.NewAPIErrorResponse(c, code, fmt.Sprintf("Failed to execute sync workflow to create Operating System on Site: %s", err), nil) + // Trigger async workflow before committing so a failure to enqueue rolls back the transaction. + // Note: first run WILL fail since data is not committed so we rely on retry. We choose that initial inocuous failure vs failing to queue silently. + if cdbm.IsIPXEType(osType) && len(dbossa) > 0 { + wid, werr := osWorkflow.ExecuteCreateOrUpdateOperatingSystemByIDWorkflow(ctx, csh.tc, targetSiteIDs, os.ID) + if werr != nil { + logger.Error().Err(werr).Msg("failed to trigger SynchronizeOperatingSystem workflow for create") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to trigger Operating System synchronization workflow", nil) } - logger.Info().Str("Workflow ID", wid).Str("Site ID", ossa.SiteID.String()).Msg("completed synchronous create Operating System workflow") + logger.Info().Str("Workflow ID", *wid).Interface("Site IDs", targetSiteIDs).Msg("triggered async CreateOrUpdateOperatingSystemByID workflow for create") } - // commit transaction + // Commit transaction. err = tx.Commit() if err != nil { logger.Error().Err(err).Msg("error committing Operating System transaction to DB") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Operating System", nil) } - // set committed so, deferred cleanup functions will do nothing txCommitted = true // create response @@ -503,27 +489,14 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to retrieve OperatingSystems - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gash.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Validate pagination request pageRequest := pagination.PageRequest{} - err = c.Bind(&pageRequest) + err := c.Bind(&pageRequest) if err != nil { logger.Warn().Err(err).Msg("error binding pagination request data into API model") return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request pagination data", nil) @@ -536,20 +509,32 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to validate pagination request data", err) } - // Validate the tenant associated with the org - tenant, err := common.GetTenantForOrg(ctx, nil, gash.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) + // Visibility rules: + // Provider admin: sees only provider-created entries (no tenant entries). + // Tenant admin: sees own entries + provider entries at tenant-accessible sites. + // Dual-role: visibility is the union of both (own tenant + own provider). + filter := cdbm.OperatingSystemFilterInput{} + + switch { + case ip != nil && tenant == nil: + // Provider admin only: sees only provider-created entries. + filter.InfrastructureProviderID = &ip.ID + case tenant != nil && ip == nil: + // Tenant admin only: own entries + provider entries at tenant-accessible sites. + filter.TenantIDs = []uuid.UUID{tenant.ID} + if providerIP, iperr := common.GetInfrastructureProviderForOrg(ctx, nil, gash.dbSession, org); iperr == nil { + filter.InfrastructureProviderID = &providerIP.ID + tenantSiteIDs, tsErr := getTenantSiteIDs(ctx, gash.dbSession, tenant.ID) + if tsErr != nil { + logger.Error().Err(tsErr).Msg("error retrieving tenant site IDs for visibility filter") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to determine site access for tenant", nil) + } + filter.ProviderOSVisibleAtSiteIDs = &tenantSiteIDs } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) - } - - filter := cdbm.OperatingSystemFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - Orgs: []string{org}, + case tenant != nil && ip != nil: + // Dual-role: own tenant + own provider entries, no site restriction. + filter.TenantIDs = []uuid.UUID{tenant.ID} + filter.InfrastructureProviderID = &ip.ID } // Get and validate includeRelation params @@ -572,14 +557,22 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to retrieve Site specified in query", nil) } - // Determine if tenant has access to requested site - _, err = tsDAO.GetByTenantIDAndSiteID(ctx, nil, tenant.ID, site.ID, nil) - if err != nil { - if err == cdb.ErrDoesNotExist { - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant is not associated with Site specified in query", nil) + // Determine if caller has access to the requested site. + // Tenant path: TenantSite association exists. + // Provider path: site belongs to the caller's infrastructure provider. + tenantHasAccess := false + if tenant != nil { + _, tsErr := tsDAO.GetByTenantIDAndSiteID(ctx, nil, tenant.ID, site.ID, nil) + if tsErr == nil { + tenantHasAccess = true + } else if tsErr != cdb.ErrDoesNotExist { + logger.Warn().Err(tsErr).Msg("error retrieving Tenant Site association from DB") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to determine if Tenant has access to Site specified in query, DB error", nil) } - logger.Warn().Err(err).Msg("error retrieving Tenant Site association from DB") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to determine if Tenant has access to Site specified in query, DB error", nil) + } + providerHasAccess := ip != nil && site.InfrastructureProviderID == ip.ID + if !tenantHasAccess && !providerHasAccess { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Caller is not associated with Site specified in query", nil) } filter.SiteIDs = append(filter.SiteIDs, site.ID) } @@ -615,7 +608,7 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { statusError := validation.Errors{ "status": errors.New(status), } - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Status value in query", statusError) + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid Status value in query: %s", status), statusError) } filter.Statuses = append(filter.Statuses, status) } @@ -690,40 +683,39 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { dbossaMap[dbossa.OperatingSystemID] = append(dbossaMap[dbossa.OperatingSystemID], curVal) } - // Get all TenantSite records for the Tenant + // Get all TenantSite records for the Tenant (only relevant when the caller + // is acting as a Tenant; provider-only admins have no tenant-site context). sttsmap := map[uuid.UUID]*cdbm.TenantSite{} - tsDAO = cdbm.NewTenantSiteDAO(gash.dbSession) - tss, _, err := tsDAO.GetAll( - ctx, - nil, - cdbm.TenantSiteFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - SiteIDs: siteIDs, - }, - cdbp.PageInput{ - Limit: cdb.GetIntPtr(cdbp.TotalLimit), - }, - nil, - ) - if err != nil { - logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) - } + if tenant != nil { + tsDAO = cdbm.NewTenantSiteDAO(gash.dbSession) + tss, _, err := tsDAO.GetAll( + ctx, + nil, + cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + SiteIDs: siteIDs, + }, + cdbp.PageInput{ + Limit: cdb.GetIntPtr(cdbp.TotalLimit), + }, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) + } - for _, ts := range tss { - curVal := ts - sttsmap[ts.SiteID] = &curVal + for _, ts := range tss { + curVal := ts + sttsmap[ts.SiteID] = &curVal + } } // Create response apiOperatingSystems := []*model.APIOperatingSystem{} for _, os := range oss { - if os.Type == cdbm.OperatingSystemTypeImage { - fmt.Printf("Processing Operating System: %s, Type: %s\n", os.Name, os.Type) - } - curVal := os apiOperatingSystem := model.NewAPIOperatingSystem(&curVal, ssdMap[os.ID.String()], dbossaMap[os.ID], sttsmap) apiOperatingSystems = append(apiOperatingSystems, apiOperatingSystem) @@ -786,22 +778,9 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to retrieve OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gsh.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Get and validate includeRelation params @@ -825,17 +804,6 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { osDAO := cdbm.NewOperatingSystemDAO(gsh.dbSession) - // Validate the tenant for which this OperatingSystem is being retrieved - tenant, err := common.GetTenantForOrg(ctx, nil, gsh.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) - } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) - } - // Check that operating system exists os, err := osDAO.GetByID(ctx, nil, sID, qIncludeRelations) if err != nil { @@ -846,10 +814,68 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Could not retrieve OperatingSystem to update", nil) } - // verify tenant matches - if os.TenantID == nil || tenant.ID != *os.TenantID { - logger.Warn().Msg("tenant in org does not match tenant in operating system") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant for OperatingSystem in request does not match tenant in org", nil) + // Visibility check with role-based rules: + // Provider admin: can only see provider-owned entries. + // Tenant admin: can see own entries + provider entries at accessible sites. + // Dual-role: can see both tenant and provider entries. + ownedByTenant := tenant != nil && os.TenantID != nil && *os.TenantID == tenant.ID + ownedByProvider := ip != nil && os.InfrastructureProviderID != nil && *os.InfrastructureProviderID == ip.ID + + // A tenant-only caller may also view provider-owned OSes belonging to the org's + // provider (subject to site-scoped visibility checked below). Lazy-fetch the + // org's provider to evaluate that case. + if !ownedByProvider && ip == nil && os.InfrastructureProviderID != nil { + if providerIP, iperr := common.GetInfrastructureProviderForOrg(ctx, nil, gsh.dbSession, org); iperr == nil { + ownedByProvider = *os.InfrastructureProviderID == providerIP.ID + } + } + + if !ownedByTenant && !ownedByProvider { + logger.Warn().Msg("operating system does not belong to the tenant or provider in org") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to the tenant or infrastructure provider in org", nil) + } + + // If caller has dual role (Tenant+Provider) we already know we can go forward. + // Otherwise we need additional checks: + if !(tenant != nil && ip != nil) { + if ip != nil && !ownedByProvider { + logger.Warn().Msg("provider admin cannot view tenant-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to the infrastructure provider in org", nil) + } + if tenant != nil && !ownedByTenant && ownedByProvider { + // Tenant admin seeing a provider-owned entry: verify site-scoped visibility. + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(gsh.dbSession) + ossas, _, ossaErr := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}}, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if ossaErr != nil { + logger.Error().Err(ossaErr).Msg("error retrieving OS site associations for visibility check") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify site access for Operating System", nil) + } + + tenantSiteIDs, tsErr := getTenantSiteIDs(ctx, gsh.dbSession, tenant.ID) + if tsErr != nil { + logger.Error().Err(tsErr).Msg("error retrieving tenant site IDs for visibility check") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to determine site access for tenant", nil) + } + tsSet := make(map[uuid.UUID]struct{}, len(tenantSiteIDs)) + for _, sid := range tenantSiteIDs { + tsSet[sid] = struct{}{} + } + visible := false + for _, ossa := range ossas { + if _, ok := tsSet[ossa.SiteID]; ok { + visible = true + break + } + } + if !visible { + logger.Warn().Msg("provider-owned OS has no site associations at sites accessible to the tenant") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System is not associated with any site accessible to the caller", nil) + } + } } // get status details for the response @@ -860,28 +886,28 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Status Details for OperatingSystem", nil) } - dbossas := []cdbm.OperatingSystemSiteAssociation{} - sttsmap := map[uuid.UUID]*cdbm.TenantSite{} - if os.Type == cdbm.OperatingSystemTypeImage { - // Get all OperatingSystemSiteAssociations - ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(gsh.dbSession) - dbossas, _, err = ossaDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{os.ID}, - }, - cdbp.PageInput{ - Limit: cdb.GetIntPtr(cdbp.TotalLimit), - }, - []string{cdbm.SiteRelationName}, - ) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) - } + // Get all OperatingSystemSiteAssociations (both iPXE and Image types may have them). + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(gsh.dbSession) + dbossas, _, err := ossaDAO.GetAll( + ctx, + nil, + cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{os.ID}, + }, + cdbp.PageInput{ + Limit: cdb.GetIntPtr(cdbp.TotalLimit), + }, + []string{cdbm.SiteRelationName}, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } - // Get all TenantSite records for the Tenant + // Get all TenantSite records for the Tenant (only relevant when the caller + // is acting as a Tenant; provider-only admins have no tenant-site context). + sttsmap := map[uuid.UUID]*cdbm.TenantSite{} + if tenant != nil { tsDAO := cdbm.NewTenantSiteDAO(gsh.dbSession) tss, _, err := tsDAO.GetAll( ctx, @@ -954,22 +980,9 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to update OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, ush.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Get os ID from URL param @@ -1004,6 +1017,13 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Could not retrieve OperatingSystem to update", nil) } + // Image-based OS updates are not supported via this handler; Image OS + // definitions are managed through carbide-core inventory synchronization. + if os.Type == cdbm.OperatingSystemTypeImage { + logger.Warn().Msg("attempted to update Image based Operating System via API") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Updating Image based Operating Systems is not supported", nil) + } + // Validate request attributes verr := apiRequest.Validate(os) if verr != nil { @@ -1018,41 +1038,51 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating user data in Operating System creation request", verr) } - // Validate the tenant for which this OperatingSystem is being updated - tenant, err := common.GetTenantForOrg(ctx, nil, ush.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) + // Enforce ownership: both roles are evaluated independently so a dual-role + // caller is permitted if either role authorizes the operation. + ownedByTenant := tenant != nil && os.TenantID != nil && *os.TenantID == tenant.ID && os.InfrastructureProviderID == nil + ownedByProvider := false + if ip != nil && os.InfrastructureProviderID != nil { + if *os.InfrastructureProviderID != ip.ID { + logger.Warn().Msg("provider admin cannot update operating system owned by a different provider") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only update Operating Systems owned by their own provider", nil) } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) + ownedByProvider = true } - - // verify tenant matches - if os.TenantID == nil || tenant.ID != *os.TenantID { - logger.Warn().Msg("tenant in os does not belong to tenant in org") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant for OperatingSystem in request does not match tenant in org", nil) + if !ownedByProvider && !ownedByTenant { + if ip != nil && tenant == nil { + logger.Warn().Msg("provider admin cannot update tenant-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only update provider-owned Operating Systems", nil) + } + if tenant != nil && ip == nil { + logger.Warn().Msg("tenant admin cannot update provider-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant Admin can only update their own Operating Systems", nil) + } + logger.Warn().Msg("user does not have permission to update this operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to your tenant or infrastructure provider", nil) } - // check for name uniqueness for the tenant, ie, tenant cannot have another os with same name + // Check for name uniqueness within the owner's scope (provider or tenant). if apiRequest.Name != nil && *apiRequest.Name != os.Name { + uniquenessFilter := cdbm.OperatingSystemFilterInput{Names: []string{*apiRequest.Name}} + if os.InfrastructureProviderID != nil { + uniquenessFilter.InfrastructureProviderID = os.InfrastructureProviderID + } else { + uniquenessFilter.TenantIDs = []uuid.UUID{tenant.ID} + } oss, tot, serr := osDAO.GetAll( ctx, nil, - cdbm.OperatingSystemFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - Names: []string{*apiRequest.Name}, - }, + uniquenessFilter, cdbp.PageInput{}, nil, ) if serr != nil { - logger.Error().Err(serr).Msg("db error checking for name uniqueness of tenant os") + logger.Error().Err(serr).Msg("db error checking for name uniqueness of os") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update OperatingSystem due to DB error", nil) } if tot > 0 { - return cutil.NewAPIErrorResponse(c, http.StatusConflict, "Another Operating System with specified name already exists for Tenant", validation.Errors{ + return cutil.NewAPIErrorResponse(c, http.StatusConflict, fmt.Sprintf("Operating System: %s with specified name already exists", oss[0].ID.String()), validation.Errors{ "id": errors.New(oss[0].ID.String()), }) } @@ -1061,60 +1091,6 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { dbossas := []cdbm.OperatingSystemSiteAssociation{} sttsmap := map[uuid.UUID]*cdbm.TenantSite{} ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(ush.dbSession) - tsDAO := cdbm.NewTenantSiteDAO(ush.dbSession) - - // Verify Tenant Site Association - // Verify if Site is in Registered state - if os.Type == cdbm.OperatingSystemTypeImage { - dbossas, _, err = ossaDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{os.ID}, - }, - cdbp.PageInput{}, - []string{cdbm.SiteRelationName}, - ) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) - } - - // Get all TenantSite records for the Tenant - tss, _, err := tsDAO.GetAll( - ctx, - nil, - cdbm.TenantSiteFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - }, - cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, - nil, - ) - if err != nil { - logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) - } - - for _, ts := range tss { - cts := ts - sttsmap[ts.SiteID] = &cts - } - - // Verify if associated Site is not registered state - // Verify if current tenant not associated Site - for _, dbosa := range dbossas { - if dbosa.Site.Status != cdbm.SiteStatusRegistered { - logger.Warn().Msg(fmt.Sprintf("unable to update Operating System. Site: %s. Site is not in Registered state", dbosa.Site.Name)) - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to update Operating System, Associated Site: %s is not in Registered state", dbosa.Site.Name), nil) - } - - // Validate the TenantSite exists for current tenant and this site - _, ok := sttsmap[dbosa.SiteID] - if !ok { - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Unable to update associate Operating System with Site: %s, Tenant does not have access to Site", dbosa.Site.Name), nil) - } - } - } // start a database transaction tx, err := cdb.BeginTx(ctx, ush.dbSession, &sql.TxOptions{}) @@ -1125,23 +1101,18 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { txCommitted := false defer common.RollbackTx(ctx, tx, &txCommitted) - // Save update status in DB - osStatus := db.GetStrPtr(cdbm.OperatingSystemStatusReady) - osStatusMessage := "Operating System has been updated and ready for use" + // Save update status in DB. + // Status goes to Syncing since updates are pushed asynchronously. + osStatus := db.GetStrPtr(cdbm.OperatingSystemStatusSyncing) + osStatusMessage := "received Operating System update request, syncing" if apiRequest.IsActive != nil && !*apiRequest.IsActive { osStatus = db.GetStrPtr(cdbm.OperatingSystemStatusDeactivated) osStatusMessage = "Operating System has been deactivated" if apiRequest.DeactivationNote != nil && *apiRequest.DeactivationNote != "" { osStatusMessage += ". " + *apiRequest.DeactivationNote } - } else { - if apiRequest.IsActive != nil && *apiRequest.IsActive { - osStatusMessage = "Operating System has been reactivated and is ready for use" - } - if os.Type == cdbm.OperatingSystemTypeImage { - osStatus = db.GetStrPtr(cdbm.OperatingSystemStatusSyncing) - osStatusMessage = "received Operating System update request, syncing" - } + } else if apiRequest.IsActive != nil && *apiRequest.IsActive { + osStatusMessage = "Operating System has been reactivated, syncing" } // When switching from inactive to active, clear deactivation note @@ -1155,24 +1126,27 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { } } uos, err := osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{ - OperatingSystemId: osID, - Name: apiRequest.Name, - Description: apiRequest.Description, - ImageURL: apiRequest.ImageURL, - ImageSHA: apiRequest.ImageSHA, - ImageAuthType: apiRequest.ImageAuthType, - ImageAuthToken: apiRequest.ImageAuthToken, - ImageDisk: apiRequest.ImageDisk, - RootFsId: apiRequest.RootFsID, - RootFsLabel: apiRequest.RootFsLabel, - IpxeScript: apiRequest.IpxeScript, - UserData: apiRequest.UserData, - IsCloudInit: apiRequest.IsCloudInit, - AllowOverride: apiRequest.AllowOverride, - PhoneHomeEnabled: apiRequest.PhoneHomeEnabled, - IsActive: apiRequest.IsActive, - DeactivationNote: deactivationNote, - Status: osStatus, + OperatingSystemId: osID, + Name: apiRequest.Name, + Description: apiRequest.Description, + ImageURL: apiRequest.ImageURL, + ImageSHA: apiRequest.ImageSHA, + ImageAuthType: apiRequest.ImageAuthType, + ImageAuthToken: apiRequest.ImageAuthToken, + ImageDisk: apiRequest.ImageDisk, + RootFsId: apiRequest.RootFsID, + RootFsLabel: apiRequest.RootFsLabel, + IpxeScript: apiRequest.IpxeScript, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + UserData: apiRequest.UserData, + IsCloudInit: apiRequest.IsCloudInit, + AllowOverride: apiRequest.AllowOverride, + PhoneHomeEnabled: apiRequest.PhoneHomeEnabled, + IsActive: apiRequest.IsActive, + DeactivationNote: deactivationNote, + Status: osStatus, }) if err != nil { logger.Error().Err(err).Msg("error updating Operating System in DB") @@ -1194,115 +1168,31 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Status Details for Operating System", nil) } - // If OS is Image based, update version too - // Retrieve Operating System Associations details - // Trigger workflows to sync Image based Operating System with various Sites - if uos.Type == cdbm.OperatingSystemTypeImage { - for _, dbossa := range dbossas { - _, err = ossaDAO.Update( - ctx, - tx, - cdbm.OperatingSystemSiteAssociationUpdateInput{ - OperatingSystemSiteAssociationID: dbossa.ID, - Status: cdb.GetStrPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing), - }, - ) - if err != nil { - logger.Error().Err(serr).Msg("unable to update the Operating System association record in DB") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update Operating System Site Association status, DB error", nil) - } - - // Create Status details - _, serr = sdDAO.CreateFromParams(ctx, tx, dbossa.ID.String(), *cdb.GetStrPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing), - cdb.GetStrPtr("received Operating System Association update request, syncing")) - if serr != nil { - logger.Error().Err(serr).Msg("error creating Status Detail DB entry") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Status Detail for Operating System Site Association", nil) - } - - // Update Operating System Association version - updatedOssa, err := ossaDAO.GenerateAndUpdateVersion(ctx, tx, dbossa.ID) - if err != nil { - logger.Error().Err(err).Msg("error updating version for updated Operating System Association") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to set version for updated Operating System Site Association, DB error", nil) - } - - // Get the temporal client for the site we are working with. - stc, err := ush.scp.GetClientByID(dbossa.SiteID) - if err != nil { - logger.Error().Err(err).Msg("failed to retrieve Temporal client for Site") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve client for Site", nil) - } - - updateOsRequest := &cwssaws.OsImageAttributes{ - Id: &cwssaws.UUID{Value: common.GetSiteOperatingSystemtID(uos).String()}, - Name: &uos.Name, - Description: uos.Description, - TenantOrganizationId: tenant.Org, - SourceUrl: *uos.ImageURL, - Digest: *uos.ImageSHA, - CreateVolume: uos.EnableBlockStorage, - AuthType: uos.ImageAuthType, - AuthToken: uos.ImageAuthToken, - RootfsId: uos.RootFsID, - RootfsLabel: uos.RootFsLabel, - } - - workflowOptions := temporalClient.StartWorkflowOptions{ - ID: "image-os-update-" + updatedOssa.SiteID.String() + "-" + uos.ID.String() + "-" + *updatedOssa.Version, - WorkflowExecutionTimeout: cutil.WorkflowExecutionTimeout, - TaskQueue: queue.SiteTaskQueue, - } - - logger.Info().Str("Site ID", dbossa.SiteID.String()).Msg("triggering Image based Operating System update workflow ") - - // Add context deadlines - ctx, cancel := context.WithTimeout(ctx, cutil.WorkflowContextTimeout) - defer cancel() - - // Trigger Site workflow - we, err := stc.ExecuteWorkflow(ctx, workflowOptions, "UpdateOsImage", updateOsRequest) - - if err != nil { - logger.Error().Err(err).Msg("failed to synchronously start Temporal workflow to update Operating System") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed start sync workflow to update Operating System on Site: %s", err), nil) - } - - wid := we.GetID() - logger.Info().Str("Workflow ID", wid).Msg("executed synchronous update Operating System workflow") - - // Block until the workflow has completed and returned success/error. - err = we.Get(ctx, nil) - if err != nil { - var timeoutErr *tp.TimeoutError - if errors.As(err, &timeoutErr) || err == context.DeadlineExceeded || ctx.Err() != nil { - - logger.Error().Err(err).Msg("failed to update Operating System, timeout occurred executing workflow on Site.") - - // Create a new context deadlines - newctx, newcancel := context.WithTimeout(context.Background(), cutil.WorkflowContextNewAfterTimeout) - defer newcancel() - - // Initiate termination workflow - serr := stc.TerminateWorkflow(newctx, wid, "", "timeout occurred executing update Operating System workflow") - if serr != nil { - logger.Error().Err(serr).Msg("failed to execute terminate Temporal workflow for updating Operating System") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to terminate synchronous Operating System update workflow after timeout, Cloud and Site data may be de-synced: %s", serr), nil) - } + // Load existing site associations for the response and trigger async sync workflow. + dbossas, _, err = ossaDAO.GetAll(ctx, tx, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{uos.ID}}, + cdbp.PageInput{}, []string{cdbm.SiteRelationName}) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } - logger.Info().Str("Workflow ID", wid).Msg("initiated terminate synchronous update Operating System workflow successfully") + if len(dbossas) > 0 && cdbm.IsIPXEType(os.Type) { + targetSiteIDs := make([]uuid.UUID, len(dbossas)) + for i, dbossa := range dbossas { + targetSiteIDs[i] = dbossa.SiteID + } - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to update Operating System, timeout occurred executing workflow on Site: %s", err), nil) - } - code, err := common.UnwrapWorkflowError(err) - logger.Error().Err(err).Msg("failed to synchronously execute Temporal workflow to update Operating System") - return cutil.NewAPIErrorResponse(c, code, fmt.Sprintf("Failed to execute sync workflow to update Operating System on Site: %s", err), nil) - } - logger.Info().Str("Workflow ID", wid).Str("Site ID", dbossa.SiteID.String()).Msg("completed synchronous update Operating System workflow") + // Trigger async workflow before committing so a failure to enqueue rolls back the transaction. + wid, werr := osWorkflow.ExecuteCreateOrUpdateOperatingSystemByIDWorkflow(ctx, ush.tc, targetSiteIDs, uos.ID) + if werr != nil { + logger.Error().Err(werr).Interface("Site IDs", targetSiteIDs).Msg("failed to trigger CreateOrUpdateOperatingSystemByID workflow for update") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to trigger Oprating System update on Sites", nil) } + logger.Info().Str("Workflow ID", *wid).Interface("Site IDs", targetSiteIDs).Msg("triggered async CreateOrUpdateOperatingSystemByID workflow for update on Sites") } - // commit transaction + // Commit transaction. err = tx.Commit() if err != nil { logger.Error().Err(err).Msg("error updating OperatingSystem in DB") @@ -1358,22 +1248,9 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to delete OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, dsh.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Get operating system ID from URL param @@ -1387,17 +1264,6 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Operating System ID in URL", nil) } - // Validate the tenant for which this OperatingSystem is being updated - tenant, err := common.GetTenantForOrg(ctx, nil, dsh.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) - } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) - } - // Check that operating system exists osDAO := cdbm.NewOperatingSystemDAO(dsh.dbSession) os, err := osDAO.GetByID(ctx, nil, osID, nil) @@ -1409,33 +1275,50 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Could not retrieve Operating System to delete", nil) } - // verify tenant matches - if os.TenantID == nil || tenant.ID != *os.TenantID { - logger.Warn().Msg("tenant in os does not belong to tenant in org") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant for Operating System in request does not match tenant in org", nil) + // Enforce ownership: both roles are evaluated independently so a dual-role + // caller is permitted if either role authorizes the operation. + ownedByTenantD := tenant != nil && os.TenantID != nil && *os.TenantID == tenant.ID && os.InfrastructureProviderID == nil + ownedByProviderD := false + if ip != nil && os.InfrastructureProviderID != nil { + if *os.InfrastructureProviderID != ip.ID { + logger.Warn().Msg("provider admin cannot delete operating system owned by a different provider") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only delete Operating Systems owned by their own provider", nil) + } + ownedByProviderD = true + } + if !ownedByProviderD && !ownedByTenantD { + if ip != nil && tenant == nil { + logger.Warn().Msg("provider admin cannot delete tenant-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only delete provider-owned Operating Systems", nil) + } + if tenant != nil && ip == nil { + logger.Warn().Msg("tenant admin cannot delete provider-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant Admin can only delete their own Operating Systems", nil) + } + logger.Warn().Msg("user does not have permission to delete this operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to your tenant or infrastructure provider", nil) } - // Verify if tenant associated with Site in case of Image based OS - // Verify Tenant Site Association - // Verify if Site is in Registered state + // Retrieve site associations for this Operating System (both iPXE and Image types + // may have associations that need per-site workflow propagation on delete). ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dsh.dbSession) - ossasToDelete := []cdbm.OperatingSystemSiteAssociation{} - if os.Type == cdbm.OperatingSystemTypeImage { - ossasToDelete, _, err = ossaDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{os.ID}, - }, - cdbp.PageInput{}, - []string{cdbm.SiteRelationName}, - ) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) - } + ossasToDelete, _, err := ossaDAO.GetAll( + ctx, + nil, + cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{os.ID}, + }, + cdbp.PageInput{}, + []string{cdbm.SiteRelationName}, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } - // Verify if associated Site is not registered state + // For image-based OS, verify all associated sites are in Registered state + // TODO: Do we not want this for iPXE-based OSes? + if os.Type == cdbm.OperatingSystemTypeImage { for _, dbosa := range ossasToDelete { if dbosa.Site.Status != cdbm.SiteStatusRegistered { logger.Warn().Msg(fmt.Sprintf("unable to delete Operating System. Site: %s. is not in Registered state", dbosa.SiteID.String())) @@ -1447,7 +1330,11 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { // verify no instances are using the os isDAO := cdbm.NewInstanceDAO(dsh.dbSession) - instances, _, err := isDAO.GetAll(ctx, nil, cdbm.InstanceFilterInput{TenantIDs: []uuid.UUID{tenant.ID}, OperatingSystemIDs: []uuid.UUID{os.ID}}, paginator.PageInput{}, nil) + instanceFilter := cdbm.InstanceFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}} + if tenant != nil { + instanceFilter.TenantIDs = []uuid.UUID{tenant.ID} + } + instances, _, err := isDAO.GetAll(ctx, nil, instanceFilter, paginator.PageInput{}, nil) if err != nil { logger.Error().Err(err).Msg("error retrieving Instances for Operating System from DB") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Instances for deleting operatingsystem", nil) @@ -1476,9 +1363,8 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System, could not acquire data store lock on Operating System", nil) } - // Verify if OS is image based - if os.Type == cdbm.OperatingSystemTypeImage { - + // Propagate the delete to associated sites (iPXE via DeleteOperatingSystem, Image via DeleteOsImage). + if len(ossasToDelete) > 0 { // Update Operating System to set status to Deleting _, err = osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{OperatingSystemId: os.ID, Status: cdb.GetStrPtr(cdbm.OperatingSystemStatusDeleting)}) if err != nil { @@ -1486,118 +1372,37 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System", nil) } - // Create status detail sdDAO := cdbm.NewStatusDetailDAO(dsh.dbSession) - // create a status detail record for the Operating System _, err = sdDAO.CreateFromParams(ctx, tx, os.ID.String(), cdbm.OperatingSystemStatusDeleting, cdb.GetStrPtr("received request for deletion, pending processing")) if err != nil { logger.Error().Err(err).Msg("error creating Status Detail DB entry") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Status Detail for Operating System", nil) } - // Update Status Deleting for Operating System Association for _, ossa := range ossasToDelete { if ossa.Status != cdbm.OperatingSystemSiteAssociationStatusDeleting { - // Update Operating System Association to set status to Deleting - _, err = ossaDAO.Update( - ctx, - tx, + _, err = ossaDAO.Update(ctx, tx, cdbm.OperatingSystemSiteAssociationUpdateInput{ OperatingSystemSiteAssociationID: ossa.ID, Status: cdb.GetStrPtr(cdbm.OperatingSystemSiteAssociationStatusDeleting), - }, - ) + }) if err != nil { logger.Error().Err(err).Msg("error updating Operating System Association in DB") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating Systems", nil) } - // create a status detail record for the Operating System Association _, err = sdDAO.CreateFromParams(ctx, tx, ossa.ID.String(), cdbm.OperatingSystemSiteAssociationStatusDeleting, cdb.GetStrPtr("received request for deletion, pending processing")) if err != nil { logger.Error().Err(err).Msg("error creating Status Detail DB entry") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Status Detail for Operating System Association", nil) } - - // Get the temporal client for the site we are working with. - stc, err := dsh.scp.GetClientByID(ossa.SiteID) - if err != nil { - logger.Error().Err(err).Msg("failed to retrieve Temporal client for Site") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve client for Site", nil) - } - - // Prepare the delete/release request workflow object - deleteOsRequest := &cwssaws.DeleteOsImageRequest{ - Id: &cwssaws.UUID{Value: common.GetSiteOperatingSystemtID(os).String()}, - TenantOrganizationId: tenant.Org, - } - - workflowOptions := temporalClient.StartWorkflowOptions{ - ID: "image-os-delete-" + ossa.SiteID.String() + "-" + os.ID.String() + "-" + *ossa.Version, - TaskQueue: queue.SiteTaskQueue, - } - - logger.Info().Msg("triggering Operating System delete workflow") - - // Trigger Site workflow to delete Image based OperatingSystem - we, err := stc.ExecuteWorkflow(ctx, workflowOptions, "DeleteOsImage", deleteOsRequest) - if err != nil { - logger.Error().Err(err).Msg("failed to synchronously start Temporal workflow to delete Operating System") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to start sync workflow to delete Operating System on Site: %s", err), nil) - } - - wid := we.GetID() - logger.Info().Str("Workflow ID", wid).Msg("executed synchronous delete Operating System workflow") - - // Execute the workflow synchronously - err = we.Get(ctx, nil) - // Handle skippable errors - if err != nil { - // If this was a 404 back from Carbide, we can treat the object as already having been deleted and allow things to proceed. - var applicationErr *tp.ApplicationError - if errors.As(err, &applicationErr) && applicationErr.Type() == swe.ErrTypeCarbideObjectNotFound { - logger.Warn().Msg(swe.ErrTypeCarbideObjectNotFound + " received from Site") - // Reset error to nil - err = nil - } - } - - // Check if err is still nil now that we've handled any skippable errors. - if err != nil { - var timeoutErr *tp.TimeoutError - if errors.As(err, &timeoutErr) || ctx.Err() != nil { - - logger.Error().Err(err).Msg("failed to delete Operating System, timeout occurred executing workflow on Site.") - - // Create a new context deadlines - newctx, newcancel := context.WithTimeout(context.Background(), cutil.WorkflowContextNewAfterTimeout) - defer newcancel() - - // Initiate termination workflow - serr := stc.TerminateWorkflow(newctx, wid, "", "timeout occurred executing delete Operating System workflow") - if serr != nil { - logger.Error().Err(serr).Msg("failed to terminate Temporal workflow for deleting Operating System") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to terminate synchronous Operating System deletion workflow after timeout, Cloud and Site data may be de-synced: %s", serr), nil) - } - - logger.Info().Str("Workflow ID", wid).Msg("initiated terminate synchronous delete Operating System workflow successfully") - - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to delete Operating System, timeout occurred executing workflow on Site: %s", err), nil) - } - - code, err := common.UnwrapWorkflowError(err) - logger.Error().Err(err).Msg("failed to synchronously execute Temporal workflow to delete Operating System") - return cutil.NewAPIErrorResponse(c, code, fmt.Sprintf("Failed to execute sync workflow to delete Operating System on Site: %s", err), nil) - } - - logger.Info().Str("Workflow ID", wid).Msg("completed synchronous delete Operating System workflow") } } } - // Delete OS if its not Image - // Delete OS if there is no Operating Site Association in case of Image based OS - if os.Type == cdbm.OperatingSystemTypeIPXE || len(ossasToDelete) == 0 { + // Soft-delete the OS if it has no site associations (legacy iPXE, or image-based with + // associations already cleaned up by the workflows above). + if len(ossasToDelete) == 0 { err = osDAO.Delete(ctx, tx, os.ID) if err != nil { logger.Error().Msg("error deleting Operating System record in DB") @@ -1605,17 +1410,97 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { } } - // commit transaction + // Trigger async workflow before committing so a failure to enqueue rolls back the transaction. + if len(ossasToDelete) > 0 && cdbm.IsIPXEType(os.Type) { + targetSiteIDs := make([]uuid.UUID, len(ossasToDelete)) + for i, ossa := range ossasToDelete { + targetSiteIDs[i] = ossa.SiteID + } + wid, werr := osWorkflow.ExecuteDeleteOperatingSystemByIDWorkflow(ctx, dsh.tc, targetSiteIDs, os.ID) + if werr != nil { + logger.Error().Err(werr).Interface("Site IDs", targetSiteIDs).Msg("failed to trigger DeleteOperatingSystemByID workflow for delete") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to trigger Operating System deletion on Sites", nil) + } + logger.Info().Str("Workflow ID", *wid).Interface("Site IDs", targetSiteIDs).Msg("triggered async DeleteOperatingSystemByID workflow for delete on Sites") + } + + // Commit transaction. err = tx.Commit() if err != nil { logger.Error().Err(err).Msg("error committing Operating System transaction to DB") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System", nil) } - // set committed so, deferred cleanup functions will do nothing txCommitted = true + // Image-based OSes still use the synchronous per-site workflow pattern (post-commit). + if len(ossasToDelete) > 0 && os.Type == cdbm.OperatingSystemTypeImage { + for _, ossa := range ossasToDelete { + if ossa.Status == cdbm.OperatingSystemSiteAssociationStatusDeleting { + continue + } + stc, serr := dsh.scp.GetClientByID(ossa.SiteID) + if serr != nil { + logger.Error().Err(serr).Msg("failed to retrieve Temporal client for Site") + continue + } + workflowOptions := temporalClient.StartWorkflowOptions{ + ID: "image-os-delete-" + ossa.SiteID.String() + "-" + os.ID.String() + "-" + *ossa.Version, + TaskQueue: queue.SiteTaskQueue, + } + deleteOsRequest := &cwssaws.DeleteOsImageRequest{ + Id: &cwssaws.UUID{Value: os.ID.String()}, + TenantOrganizationId: tenant.Org, + } + we, werr := stc.ExecuteWorkflow(ctx, workflowOptions, "DeleteOsImage", deleteOsRequest) + if werr != nil { + logger.Error().Err(werr).Msg("failed to start DeleteOsImage workflow") + continue + } + werr = we.Get(ctx, nil) + if werr != nil { + var applicationErr *tp.ApplicationError + if errors.As(werr, &applicationErr) && applicationErr.Type() == swe.ErrTypeCarbideObjectNotFound { + werr = nil + } + } + if werr != nil { + var timeoutErr *tp.TimeoutError + if errors.As(werr, &timeoutErr) { + logger.Error().Err(werr).Msg("failed to delete Operating System, timeout occurred executing workflow on Site.") + newctx := context.Background() + serr := stc.TerminateWorkflow(newctx, we.GetID(), "", "timeout occurred executing delete Operating System workflow") + if serr != nil { + logger.Error().Err(serr).Msg("failed to execute terminate Temporal workflow for deleting Operating System") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to terminate synchronous Operating System delete workflow after timeout, Cloud and Site data may be de-synced: %s", serr), nil) + } + logger.Info().Str("Workflow ID", we.GetID()).Msg("initiated terminate synchronous delete Operating System workflow successfully") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to delete Operating System, timeout occurred executing workflow on Site: %s", werr), nil) + } + logger.Error().Err(werr).Str("Workflow ID", we.GetID()).Msg("DeleteOsImage workflow failed") + } + } + } + // Create response logger.Info().Msg("finishing API handler") return c.String(http.StatusAccepted, "Deletion request was accepted") } + +// getTenantSiteIDs returns the IDs of all sites the given tenant has access to. +func getTenantSiteIDs(ctx context.Context, dbSession *db.Session, tenantID uuid.UUID) ([]uuid.UUID, error) { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, err := tsDAO.GetAll(ctx, nil, + cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenantID}}, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + return nil, err + } + ids := make([]uuid.UUID, len(tss)) + for i, ts := range tss { + ids[i] = ts.SiteID + } + return ids, nil +} diff --git a/api/pkg/api/handler/operatingsystem_ownership_test.go b/api/pkg/api/handler/operatingsystem_ownership_test.go new file mode 100644 index 000000000..957d22468 --- /dev/null +++ b/api/pkg/api/handler/operatingsystem_ownership_test.go @@ -0,0 +1,960 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file extends operatingsystem_test.go with tests that validate the +// ownership model (TenantID vs InfrastructureProviderID) and role-based +// access-control enforcement introduced alongside the bi-directional sync +// feature. Each test function is self-contained and uses a fresh schema. +// +// Roles under test +// - ipUser — FORGE_PROVIDER_ADMIN only +// - tnUser — FORGE_TENANT_ADMIN only +// - dualUser — both roles (either role may authorize the operation) +// +// Ownership invariants verified +// - Provider Admin → InfrastructureProviderID set, TenantID nil +// - Tenant Admin → TenantID set, InfrastructureProviderID nil +// - Dual-role → permitted if either role authorizes the action; +// when both allow it, Provider Admin takes priority +// for ownership assignment +// +// Cross-ownership visibility (GetAll / GetByID) +// - Provider Admin sees only provider-owned OSes (none from tenants). +// - Tenant Admin sees own OSes + provider-owned OSes that have site +// associations at sites accessible to the tenant. +// +// Mutation enforcement (Update / Delete) +// - Provider Admin can mutate only provider-owned OSes. +// - Tenant Admin can mutate only tenant-owned OSes. +// - Dual-role user is permitted if either role authorizes the action. + +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/handler/util/common" + "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/model" + sc "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/client/site" + "github.com/NVIDIA/ncx-infra-controller-rest/common/pkg/otelecho" + cdb "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" + cdbm "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/model" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + tmocks "go.temporal.io/sdk/mocks" +) + +// ─── shared setup helpers ──────────────────────────────────────────────────── + +// ownershipTestEnv contains all DB fixtures that the ownership-related tests +// share. Each test function calls newOwnershipTestEnv and receives a freshly +// reset schema. +type ownershipTestEnv struct { + dbSession *cdb.Session + // Shared org that has both an InfrastructureProvider and a Tenant. + // Users in this org can carry either or both roles. + sharedOrg string + ip *cdbm.InfrastructureProvider + tenant *cdbm.Tenant + site *cdbm.Site // registered site belonging to ip + + // Users + ipUser *cdbm.User // FORGE_PROVIDER_ADMIN only + tnUser *cdbm.User // FORGE_TENANT_ADMIN only + dualUser *cdbm.User // both roles + + // Temporal mocks (permissive — match any workflow invocation) + tempClient *tmocks.Client + scp *sc.ClientPool +} + +func newOwnershipTestEnv(t *testing.T) *ownershipTestEnv { + t.Helper() + + dbSession := testMachineInitDB(t) + t.Cleanup(func() { dbSession.Close() }) + common.TestSetupSchema(t, dbSession) + + sharedOrg := "shared-org" + + ip := testMachineBuildInfrastructureProvider(t, dbSession, sharedOrg, "shared-ip") + require.NotNil(t, ip) + + tenant := testMachineBuildTenant(t, dbSession, sharedOrg, "shared-tenant") + require.NotNil(t, tenant) + + site := testMachineBuildSite(t, dbSession, ip, "shared-site", cdbm.SiteStatusRegistered) + require.NotNil(t, site) + + // TenantSite so tenant users can reference the site. + tnu := testMachineBuildUser(t, dbSession, uuid.NewString(), + []string{sharedOrg}, []string{"FORGE_TENANT_ADMIN"}) + ts := testBuildTenantSiteAssociation(t, dbSession, sharedOrg, tenant.ID, site.ID, tnu.ID) + require.NotNil(t, ts) + + ipUser := testMachineBuildUser(t, dbSession, uuid.NewString(), + []string{sharedOrg}, []string{"FORGE_PROVIDER_ADMIN"}) + dualUser := testMachineBuildUser(t, dbSession, uuid.NewString(), + []string{sharedOrg}, []string{"FORGE_PROVIDER_ADMIN", "FORGE_TENANT_ADMIN"}) + + // Permissive Temporal mock: accepts any ExecuteWorkflow call so that tests + // exercising the success path don't have to enumerate every signature. + wrun := &tmocks.WorkflowRun{} + wrun.On("GetID").Return("test-wf-id") + wrun.On("Get", mock.Anything, mock.Anything).Return(nil) + + tempClient := &tmocks.Client{} + tempClient.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, + mock.Anything, mock.Anything).Return(wrun, nil) + + cfg := common.GetTestConfig() + tcfg, _ := cfg.GetTemporalConfig() + scp := sc.NewClientPool(tcfg) + + // Per-site Temporal client (permissive). + siteMock := &tmocks.Client{} + siteMock.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, + mock.Anything).Return(wrun, nil) + siteMock.On("TerminateWorkflow", mock.Anything, mock.Anything, mock.Anything, + mock.Anything).Return(nil) + scp.IDClientMap[site.ID.String()] = siteMock + + return &ownershipTestEnv{ + dbSession: dbSession, + sharedOrg: sharedOrg, + ip: ip, + tenant: tenant, + site: site, + ipUser: ipUser, + tnUser: tnu, + dualUser: dualUser, + tempClient: tempClient, + scp: scp, + } +} + +// execCreateOS posts a Create request and returns the response recorder. +func (e *ownershipTestEnv) execCreateOS(t *testing.T, user *cdbm.User, body interface{}) *httptest.ResponseRecorder { + t.Helper() + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + tracer, traceCtx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(rawBody))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName") + ec.SetParamValues(e.sharedOrg) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(traceCtx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := CreateOperatingSystemHandler{ + dbSession: e.dbSession, + tc: e.tempClient, + cfg: cfg, + scp: e.scp, + } + require.NoError(t, h.Handle(ec)) + return rec +} + +// otelTraceCtx returns a no-op tracer and a context for use in handler tests. +func otelTraceCtx(t *testing.T) (interface{}, context.Context) { + t.Helper() + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, context.Background()) + return tracer, ctx +} + +// ─── Create: ownership assignment ──────────────────────────────────────────── + +// TestOperatingSystemHandler_Create_ProviderAndTenantOwnership verifies that +// the Create handler assigns ownership correctly based on the caller's role: +// +// - Provider Admin → InfrastructureProviderID = provider's ID, TenantID = nil +// - Tenant Admin → TenantID = tenant's ID, InfrastructureProviderID = nil +// - Dual-role user → permitted if either role authorizes the action; +// when both allow it, provider ownership takes priority +// +// The test also covers the "new" iPXE OS type (template-based with parameters +// and artifacts) to ensure those fields round-trip correctly. +func TestOperatingSystemHandler_Create_ProviderAndTenantOwnership(t *testing.T) { + env := newOwnershipTestEnv(t) + + ipxeScript := "ipxe-script-content" + templateName := "raw-ipxe" + scopeGlobal := cdbm.OperatingSystemScopeGlobal + + // template-based request reused for several sub-tests. + templateBody := model.APIOperatingSystemCreateRequest{ + Name: "tmpl-os-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + } + + tests := []struct { + name string + user *cdbm.User + body model.APIOperatingSystemCreateRequest + wantStatus int + wantProviderID *uuid.UUID // nil means we don't assert + wantTenantID *uuid.UUID // nil means we don't assert + wantProviderNil bool + wantTenantNil bool + }{ + { + name: "provider admin raw iPXE → forbidden (must use template)", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "prov-ipxe-" + uuid.NewString(), + IpxeScript: &ipxeScript, + }, + wantStatus: http.StatusForbidden, + }, + { + name: "provider admin template iPXE → provider-owned", + user: env.ipUser, + body: templateBody, + wantStatus: http.StatusCreated, + wantProviderID: &env.ip.ID, + wantTenantNil: true, + }, + { + name: "tenant admin raw iPXE → tenant-owned", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-ipxe-" + uuid.NewString(), + IpxeScript: &ipxeScript, + }, + wantStatus: http.StatusCreated, + wantTenantID: &env.tenant.ID, + wantProviderNil: true, + }, + { + name: "tenant admin template iPXE → tenant-owned", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-tmpl-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + wantStatus: http.StatusCreated, + wantTenantID: &env.tenant.ID, + wantProviderNil: true, + }, + { + name: "dual-role user raw iPXE → tenant-owned (tenant role authorizes)", + user: env.dualUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "dual-ipxe-" + uuid.NewString(), + IpxeScript: &ipxeScript, + }, + wantStatus: http.StatusCreated, + wantTenantID: &env.tenant.ID, + wantProviderNil: true, + }, + { + name: "dual-role user template iPXE → provider-owned", + user: env.dualUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "dual-tmpl-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + wantStatus: http.StatusCreated, + wantProviderID: &env.ip.ID, + wantTenantNil: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := env.execCreateOS(t, tc.user, tc.body) + assert.Equal(t, tc.wantStatus, rec.Code, "response body: %s", rec.Body.String()) + if rec.Code != http.StatusCreated { + return + } + + var rsp model.APIOperatingSystem + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rsp)) + + if tc.wantProviderID != nil { + require.NotNil(t, rsp.InfrastructureProviderID, + "expected InfrastructureProviderID to be set") + assert.Equal(t, tc.wantProviderID.String(), *rsp.InfrastructureProviderID) + } + if tc.wantProviderNil { + assert.Nil(t, rsp.InfrastructureProviderID, + "expected InfrastructureProviderID to be nil") + } + if tc.wantTenantID != nil { + require.NotNil(t, rsp.TenantID, "expected TenantID to be set") + assert.Equal(t, tc.wantTenantID.String(), *rsp.TenantID) + } + if tc.wantTenantNil { + assert.Nil(t, rsp.TenantID, "expected TenantID to be nil") + } + + // Verify iPXE parameters and artifacts round-trip for template OS. + if tc.body.IpxeTemplateId != nil { + assert.Equal(t, tc.body.IpxeTemplateId, rsp.IpxeTemplateId) + if len(tc.body.IpxeTemplateParameters) > 0 { + require.Len(t, rsp.IpxeTemplateParameters, len(tc.body.IpxeTemplateParameters)) + assert.Equal(t, tc.body.IpxeTemplateParameters[0].Name, rsp.IpxeTemplateParameters[0].Name) + } + if len(tc.body.IpxeTemplateArtifacts) > 0 { + require.Len(t, rsp.IpxeTemplateArtifacts, len(tc.body.IpxeTemplateArtifacts)) + assert.Equal(t, tc.body.IpxeTemplateArtifacts[0].Name, rsp.IpxeTemplateArtifacts[0].Name) + assert.Equal(t, tc.body.IpxeTemplateArtifacts[0].URL, rsp.IpxeTemplateArtifacts[0].URL) + } + } + }) + } +} + +// ─── GetAll: cross-ownership visibility ────────────────────────────────────── + +// TestOperatingSystemHandler_GetAll_CrossOwnership verifies role-based +// visibility rules: +// - Provider Admin sees only provider-owned OSes. +// - Tenant Admin sees own OSes + provider-owned OSes at accessible sites. +// - Dual-role user sees the union. +func TestOperatingSystemHandler_GetAll_CrossOwnership(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // Seed one provider-owned OS with a site association so the tenant can see it. + provOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "synced-from-core", + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe\nchain http://boot.example.com"), + IpxeOsScope: cdb.GetStrPtr(cdbm.OperatingSystemScopeLocal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(env.dbSession) + _, err = ossaDAO.Create(ctx, nil, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: provOS.ID, + SiteID: env.site.ID, + Status: cdbm.OperatingSystemSiteAssociationStatusSynced, + }) + require.NoError(t, err) + + // Seed one tenant-owned OS. + tnOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tenant-os", + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + IpxeOsScope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + + execGetAll := func(t *testing.T, user *cdbm.User) []model.APIOperatingSystem { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName") + ec.SetParamValues(env.sharedOrg) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := GetAllOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + } + require.NoError(t, h.Handle(ec)) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var rsp []model.APIOperatingSystem + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rsp)) + return rsp + } + + t.Run("provider admin sees only provider-owned OS", func(t *testing.T) { + oss := execGetAll(t, env.ipUser) + ids := make([]string, len(oss)) + for i, o := range oss { + ids[i] = o.ID + } + assert.Contains(t, ids, provOS.ID.String()) + assert.NotContains(t, ids, tnOS.ID.String(), "provider admin must not see tenant-owned OS") + }) + + t.Run("tenant admin sees own and provider-owned OS at accessible site", func(t *testing.T) { + oss := execGetAll(t, env.tnUser) + assert.GreaterOrEqual(t, len(oss), 2) + ids := make([]string, len(oss)) + for i, o := range oss { + ids[i] = o.ID + } + assert.Contains(t, ids, provOS.ID.String(), "tenant should see provider OS at accessible site") + assert.Contains(t, ids, tnOS.ID.String()) + }) + + t.Run("dual-role user sees both provider-owned and tenant-owned OSes", func(t *testing.T) { + oss := execGetAll(t, env.dualUser) + assert.GreaterOrEqual(t, len(oss), 2) + }) +} + +// ─── GetByID: cross-ownership visibility ───────────────────────────────────── + +// TestOperatingSystemHandler_GetByID_CrossOwnership verifies role-based +// visibility for individual OS fetches: +// - Provider Admin can fetch provider-owned OS, but NOT tenant-owned. +// - Tenant Admin can fetch own OS + provider-owned OS at accessible sites. +func TestOperatingSystemHandler_GetByID_CrossOwnership(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // Provider-owned OS with site association (visible to tenant via site). + provOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "prov-single", + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe"), + IpxeOsScope: cdb.GetStrPtr(cdbm.OperatingSystemScopeLocal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(env.dbSession) + _, err = ossaDAO.Create(ctx, nil, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: provOS.ID, + SiteID: env.site.ID, + Status: cdbm.OperatingSystemSiteAssociationStatusSynced, + }) + require.NoError(t, err) + + tnOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tenant-single", + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe"), + IpxeOsScope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + + execGetByID := func(t *testing.T, user *cdbm.User, osID string) *httptest.ResponseRecorder { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName", "id") + ec.SetParamValues(env.sharedOrg, osID) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := GetOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + } + require.NoError(t, h.Handle(ec)) + return rec + } + + t.Run("provider admin gets provider-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.ipUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin gets provider-owned OS at accessible site", func(t *testing.T) { + rec := execGetByID(t, env.tnUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("provider admin cannot get tenant-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.ipUser, tnOS.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin gets tenant-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.tnUser, tnOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user gets provider-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.dualUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) +} + +// ─── Update: role-based ownership enforcement ───────────────────────────────── + +// TestOperatingSystemHandler_Update_OwnershipEnforcement exercises the Update +// handler's role-based mutation rules: +// +// - Provider Admin can update only provider-owned OSes → 200 / 403 +// - Tenant Admin can update only tenant-owned OSes → 200 / 403 +// - Dual-role user is permitted if either role authorizes the action +func TestOperatingSystemHandler_Update_OwnershipEnforcement(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // Provider-owned iPXE OS (no site associations → no Temporal calls needed). + provOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "prov-update-target", + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + + // Tenant-owned iPXE OS (no site associations). + tnOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tn-update-target", + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + + newScript := "updated-ipxe-script" + updateBody := model.APIOperatingSystemUpdateRequest{ + IpxeScript: &newScript, + } + rawUpdate, err := json.Marshal(updateBody) + require.NoError(t, err) + + execUpdate := func(t *testing.T, user *cdbm.User, osID string) *httptest.ResponseRecorder { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(string(rawUpdate))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName", "id") + ec.SetParamValues(env.sharedOrg, osID) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := UpdateOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + scp: env.scp, + } + require.NoError(t, h.Handle(ec)) + return rec + } + + t.Run("provider admin updates provider-owned OS → 200", func(t *testing.T) { + rec := execUpdate(t, env.ipUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("provider admin updates tenant-owned OS → 403", func(t *testing.T) { + rec := execUpdate(t, env.ipUser, tnOS.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin updates tenant-owned OS → 200", func(t *testing.T) { + rec := execUpdate(t, env.tnUser, tnOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin updates provider-owned OS → 403", func(t *testing.T) { + rec := execUpdate(t, env.tnUser, provOS.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user updates provider-owned OS → 200 (provider role authorizes)", func(t *testing.T) { + rec := execUpdate(t, env.dualUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user updates tenant-owned OS → 200 (tenant role authorizes)", func(t *testing.T) { + rec := execUpdate(t, env.dualUser, tnOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) +} + +// ─── Delete: role-based ownership enforcement ───────────────────────────────── + +// TestOperatingSystemHandler_Delete_OwnershipEnforcement exercises the Delete +// handler's role-based mutation rules. iPXE OSes without site associations +// are used so no Temporal workflow is invoked. +// +// - Provider Admin deletes provider-owned OS → 202 +// - Provider Admin deletes tenant-owned OS → 403 +// - Tenant Admin deletes tenant-owned OS → 202 +// - Tenant Admin deletes provider-owned OS → 403 +// - Dual-role user deletes provider-owned OS → 202 (provider role authorizes) +// - Dual-role user deletes tenant-owned OS → 202 (tenant role authorizes) +func TestOperatingSystemHandler_Delete_OwnershipEnforcement(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // helper: create a fresh provider-owned iPXE OS. + newProvOS := func(suffix string) *cdbm.OperatingSystem { + os, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "prov-del-" + suffix, + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + return os + } + + // helper: create a fresh tenant-owned iPXE OS. + newTnOS := func(suffix string) *cdbm.OperatingSystem { + os, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tn-del-" + suffix, + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + return os + } + + execDelete := func(t *testing.T, user *cdbm.User, osID string) *httptest.ResponseRecorder { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodDelete, "/", nil) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName", "id") + ec.SetParamValues(env.sharedOrg, osID) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := DeleteOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + scp: env.scp, + } + require.NoError(t, h.Handle(ec)) + return rec + } + + t.Run("provider admin deletes provider-owned OS → 202", func(t *testing.T) { + os := newProvOS(uuid.NewString()) + rec := execDelete(t, env.ipUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) + + t.Run("provider admin deletes tenant-owned OS → 403", func(t *testing.T) { + os := newTnOS(uuid.NewString()) + rec := execDelete(t, env.ipUser, os.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin deletes tenant-owned OS → 202", func(t *testing.T) { + os := newTnOS(uuid.NewString()) + rec := execDelete(t, env.tnUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin deletes provider-owned OS → 403", func(t *testing.T) { + os := newProvOS(uuid.NewString()) + rec := execDelete(t, env.tnUser, os.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user deletes provider-owned OS → 202 (provider role authorizes)", func(t *testing.T) { + os := newProvOS(uuid.NewString()) + rec := execDelete(t, env.dualUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user deletes tenant-owned OS → 202 (tenant role authorizes)", func(t *testing.T) { + os := newTnOS(uuid.NewString()) + rec := execDelete(t, env.dualUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) +} + +// ─── Create: scope and site-association behaviour ───────────────────────────── + +// TestOperatingSystemHandler_Create_ScopeAndSiteAssociation verifies that: +// - Templated iPXE with Global scope auto-associates with all registered provider sites +// - Templated iPXE with Limited scope associates only with specified sites +// - Raw iPXE auto-sets scope to Global and auto-associates with tenant-accessible sites +// - Response includes correct scope, type, and site associations +func TestOperatingSystemHandler_Create_ScopeAndSiteAssociation(t *testing.T) { + env := newOwnershipTestEnv(t) + + scopeGlobal := cdbm.OperatingSystemScopeGlobal + scopeLimited := cdbm.OperatingSystemScopeLimited + templateName := "test-template" + + tests := []struct { + name string + user *cdbm.User + body model.APIOperatingSystemCreateRequest + wantStatus int + wantType string + wantScope *string + wantSiteCount int + wantProviderOwned bool + }{ + { + name: "provider admin template iPXE global scope → auto-associates with provider sites", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "prov-global-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeTemplatedIPXE, + wantScope: &scopeGlobal, + wantSiteCount: 1, // one registered site in env + wantProviderOwned: true, + }, + { + name: "provider admin template iPXE limited scope → associates with specified sites", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "prov-limited-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeLimited, + SiteIDs: []string{env.site.ID.String()}, + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeTemplatedIPXE, + wantScope: &scopeLimited, + wantSiteCount: 1, + wantProviderOwned: true, + }, + { + name: "tenant admin raw iPXE → scope auto-set to Global, associates with tenant sites", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-raw-ipxe-" + uuid.NewString(), + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeIPXE, + wantScope: &scopeGlobal, + wantSiteCount: 1, // tenant has access to env.site + }, + { + name: "tenant admin template iPXE global scope → tenant-owned, associates with tenant sites", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-tmpl-global-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeTemplatedIPXE, + wantScope: &scopeGlobal, + wantSiteCount: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := env.execCreateOS(t, tc.user, tc.body) + require.Equal(t, tc.wantStatus, rec.Code, "response body: %s", rec.Body.String()) + + var rsp model.APIOperatingSystem + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rsp)) + + require.NotNil(t, rsp.Type, "type must be set") + assert.Equal(t, tc.wantType, *rsp.Type) + + if tc.wantScope != nil { + require.NotNil(t, rsp.Scope, "scope must be set in response") + assert.Equal(t, *tc.wantScope, *rsp.Scope) + } + + assert.Len(t, rsp.SiteAssociations, tc.wantSiteCount, + "expected %d site associations, got %d", tc.wantSiteCount, len(rsp.SiteAssociations)) + + if tc.wantProviderOwned { + assert.NotNil(t, rsp.InfrastructureProviderID) + assert.Nil(t, rsp.TenantID) + } else { + assert.Nil(t, rsp.InfrastructureProviderID) + assert.NotNil(t, rsp.TenantID) + } + + assert.Equal(t, cdbm.OperatingSystemStatusSyncing, rsp.Status) + assert.True(t, len(rsp.StatusHistory) >= 1, "expected at least one status history entry") + }) + } +} + +// ─── Create: validation error paths for scope ───────────────────────────────── + +// TestOperatingSystemHandler_Create_ScopeValidationErrors verifies API-level +// rejection of invalid scope combinations that the model validation covers. +func TestOperatingSystemHandler_Create_ScopeValidationErrors(t *testing.T) { + env := newOwnershipTestEnv(t) + + scopeLocal := cdbm.OperatingSystemScopeLocal + scopeLimited := cdbm.OperatingSystemScopeLimited + templateName := "test-template" + + tests := []struct { + name string + user *cdbm.User + body model.APIOperatingSystemCreateRequest + wantStatus int + }{ + { + name: "template iPXE with Local scope → 400", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tmpl-local-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeLocal, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "template iPXE with Limited scope but no siteIds → 400", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tmpl-limited-nosites-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeLimited, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "template iPXE missing scope entirely → 400", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tmpl-noscope-" + uuid.NewString(), + IpxeTemplateId: &templateName, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "raw iPXE with scope specified → 400", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-scope-" + uuid.NewString(), + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + Scope: &scopeLimited, + }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := env.execCreateOS(t, tc.user, tc.body) + assert.Equal(t, tc.wantStatus, rec.Code, "response body: %s", rec.Body.String()) + }) + } +} diff --git a/api/pkg/api/handler/operatingsystem_test.go b/api/pkg/api/handler/operatingsystem_test.go index da805c101..4f949995d 100644 --- a/api/pkg/api/handler/operatingsystem_test.go +++ b/api/pkg/api/handler/operatingsystem_test.go @@ -99,7 +99,7 @@ func TestOperatingSystemHandler_Create(t *testing.T) { cfg := common.GetTestConfig() tempClient := &tmocks.Client{} - osObj := model.APIOperatingSystemCreateRequest{Name: "test-operating-system-1", Description: cdb.GetStrPtr("test"), InfrastructureProviderID: nil, TenantID: cdb.GetStrPtr(tenant1.ID.String()), IpxeScript: cdb.GetStrPtr("ipxe"), ImageDisk: cdb.GetStrPtr("/dev/sda"), UserData: cdb.GetStrPtr(cdmu.TestCommonCloudInit), IsCloudInit: true, AllowOverride: false} + osObj := model.APIOperatingSystemCreateRequest{Name: "test-operating-system-1", Description: cdb.GetStrPtr("test"), InfrastructureProviderID: nil, TenantID: cdb.GetStrPtr(tenant1.ID.String()), IpxeScript: cdb.GetStrPtr("ipxe"), UserData: cdb.GetStrPtr(cdmu.TestCommonCloudInit), IsCloudInit: true, AllowOverride: false} okBody, err := json.Marshal(osObj) assert.Nil(t, err) @@ -156,7 +156,7 @@ func TestOperatingSystemHandler_Create(t *testing.T) { wrun.Mock.On("Get", mock.Anything, mock.Anything).Return(nil) tempClient.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), - mock.AnythingOfType("func(internal.Context, uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("uuid.UUID"), + mock.AnythingOfType("func(internal.Context, []uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("[]uuid.UUID"), mock.AnythingOfType("uuid.UUID")).Return(wrun, nil) tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), @@ -298,7 +298,7 @@ func TestOperatingSystemHandler_Create(t *testing.T) { user: tnu, expectedErr: false, expectedStatus: http.StatusCreated, - expectedOperatingSystemStatus: cdbm.OperatingSystemStatusReady, + expectedOperatingSystemStatus: cdbm.OperatingSystemStatusSyncing, expectedStatusHistoryCount: 1, verifyChildSpanner: true, }, @@ -1552,7 +1552,7 @@ func TestOperatingSystemHandler_Update(t *testing.T) { wrun.Mock.On("Get", mock.Anything, mock.Anything).Return(nil) tempClient.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), - mock.AnythingOfType("func(internal.Context, uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("uuid.UUID"), + mock.AnythingOfType("func(internal.Context, []uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("[]uuid.UUID"), mock.AnythingOfType("uuid.UUID")).Return(wrun, nil) tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), @@ -1629,7 +1629,7 @@ func TestOperatingSystemHandler_Update(t *testing.T) { user: user, osID: os2.ID.String(), expectedErr: true, - expectedStatus: http.StatusBadRequest, + expectedStatus: http.StatusForbidden, }, { name: "error when req body doesnt bind", @@ -1697,32 +1697,22 @@ func TestOperatingSystemHandler_Update(t *testing.T) { verifyChildSpanner: true, }, { - name: "should succeed to deactivate active OS", + name: "should reject deactivate on Image OS", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyDeactivate), osID: os10.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqDeactivate.Name, - expectedDesc: updReqDeactivate.Description, - expectedIsActive: cdb.GetBoolPtr(false), - expectedDeactivationNote: updReqDeactivate.DeactivationNote, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "should succeed to deactivate active OS without Deactivation Note", + name: "should reject deactivate on Image OS without Deactivation Note", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyDeactivateNoNote), osID: os11.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqDeactivateNoNote.Name, - expectedDesc: updReqDeactivateNoNote.Description, - expectedIsActive: cdb.GetBoolPtr(false), - expectedDeactivationNote: updReqDeactivateNoNote.DeactivationNote, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { name: "should fail to change Deactivation Note for an active OS", @@ -1734,53 +1724,42 @@ func TestOperatingSystemHandler_Update(t *testing.T) { expectedStatus: http.StatusBadRequest, }, { - name: "should succeed to change Deactivation Note on deactivated OS", + name: "should reject change of Deactivation Note on deactivated Image OS", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyChangeNote), osID: os12.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqChangeNote.Name, - expectedDesc: updReqChangeNote.Description, - expectedIsActive: cdb.GetBoolPtr(false), - expectedDeactivationNote: updReqChangeNote.DeactivationNote, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "should succeed to activate deactivated OS (3/4)", + name: "should reject activate on deactivated Image OS", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyActivate), osID: os13.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqActivate.Name, - expectedDesc: updReqActivate.Description, - expectedIsActive: cdb.GetBoolPtr(false), - expectedDeactivationNote: nil, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "success when updated with required valid imageURL attribute", - reqOrgName: ipOrg1, - user: user, - reqBody: string(okBodyImageUrl), - reqUpdateModel: &updReqImageUrl, - osID: os5.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - expectedImageURL: cdb.GetStrPtr("http://newimagepath.iso"), + name: "should reject imageURL update on Image OS", + reqOrgName: ipOrg1, + user: user, + reqBody: string(okBodyImageUrl), + reqUpdateModel: &updReqImageUrl, + osID: os5.ID.String(), + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "error when updated with required valid imageURL attribute failed with context deadline error", + name: "should reject imageURL update on Image OS in second org", reqOrgName: ipOrg2, user: user, reqBody: string(okBodyImageUrl), reqUpdateModel: &updReqImageUrl, osID: os9.ID.String(), expectedErr: true, - expectedStatus: http.StatusInternalServerError, + expectedStatus: http.StatusBadRequest, }, } for _, tc := range tests { @@ -2085,7 +2064,7 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { wrun.Mock.On("Get", mock.Anything, mock.Anything).Return(nil) tempClient.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), - mock.AnythingOfType("func(internal.Context, uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("uuid.UUID"), + mock.AnythingOfType("func(internal.Context, []uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("[]uuid.UUID"), mock.AnythingOfType("uuid.UUID")).Return(wrun, nil) tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), @@ -2175,7 +2154,7 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { user: tnu, osID: os3.ID.String(), expectedErr: true, - expectedStatus: http.StatusBadRequest, + expectedStatus: http.StatusForbidden, }, { name: "error when instance present for os", diff --git a/api/pkg/api/handler/site.go b/api/pkg/api/handler/site.go index 0e09d4b77..620ea5ef5 100644 --- a/api/pkg/api/handler/site.go +++ b/api/pkg/api/handler/site.go @@ -276,6 +276,45 @@ func (csh CreateSiteHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create workflow namespace for Site", nil) } + // Auto-associate global-scoped iPXE OSes for this provider with the newly created site. + // This ensures that OSes marked scope="Global" are automatically available on every new site + // added to the provider without requiring the user to update each OS individually. + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(csh.dbSession) + globalOSes, _, goserr := cdbm.NewOperatingSystemDAO(csh.dbSession).GetAll( + ctx, tx, + cdbm.OperatingSystemFilterInput{ + InfrastructureProviderID: &ip.ID, + OsTypes: []string{cdbm.OperatingSystemTypeIPXE, cdbm.OperatingSystemTypeTemplatedIPXE}, + Scopes: []string{cdbm.OperatingSystemScopeGlobal}, + }, + cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, + nil, + ) + if goserr != nil { + logger.Error().Err(goserr).Msg("error retrieving global-scoped OSes for auto-expansion") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve global-scoped OSes, DB error", nil) + } + for _, gos := range globalOSes { + if _, aerr := ossaDAO.Create(ctx, tx, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: gos.ID, + SiteID: st.ID, + Status: cdbm.OperatingSystemSiteAssociationStatusSyncing, + CreatedBy: dbUser.ID, + }); aerr != nil { + logger.Error().Err(aerr).Str("osID", gos.ID.String()).Msg("Failed to auto-associate global OS with new site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to associate global-scoped Operating Systems with new Site", nil) + } + } + if len(globalOSes) > 0 { + logger.Info().Int("count", len(globalOSes)).Msg("Auto-associated global-scoped OSes with new site") + } + + // Global-scoped OS propagation to the new site is intentionally not triggered here. + // The site is not yet in Registered state at creation time, so any workflow dispatch + // would fail or be meaningless. The SynchronizeOperatingSystem workflow (triggered on + // OS create/update) and the inventory reconciliation cycle will push the OSSAs once + // the site-agent connects and the site transitions to Registered. + err = tx.Commit() if err != nil { logger.Error().Err(err).Msg("failed to commit transaction, error creating site") diff --git a/api/pkg/api/handler/util/common/common.go b/api/pkg/api/handler/util/common/common.go index ab1caa855..d7408c5d7 100644 --- a/api/pkg/api/handler/util/common/common.go +++ b/api/pkg/api/handler/util/common/common.go @@ -119,14 +119,6 @@ func GetSiteNetworkSegmentID(s *cdbm.Subnet) *uuid.UUID { } } -func GetSiteOperatingSystemtID(o *cdbm.OperatingSystem) *uuid.UUID { - if o.ControllerOperatingSystemID != nil { - return o.ControllerOperatingSystemID - } else { - return &o.ID - } -} - // GetInfrastructureProviderForOrg gets the infrastructureProvider for org func GetInfrastructureProviderForOrg(ctx context.Context, tx *cdb.Tx, dbSession *cdb.Session, org string) (*cdbm.InfrastructureProvider, error) { ipDAO := cdbm.NewInfrastructureProviderDAO(dbSession) diff --git a/api/pkg/api/model/instance.go b/api/pkg/api/model/instance.go index 2a60f189c..9aed1f8fd 100644 --- a/api/pkg/api/model/instance.go +++ b/api/pkg/api/model/instance.go @@ -584,7 +584,9 @@ func (icr *APIInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg *conf // Merge things in from OS when not // found in request. - if mergedIpxeScript == nil { + // Templated iPXE uses server-side template resolution, so the OS's + // inline ipxeScript must not be copied into the request. + if mergedIpxeScript == nil && os.Type == cdbm.OperatingSystemTypeIPXE { // If no script was sent in the request, and the // request is selecting the operating system, // give it precedence. @@ -914,7 +916,9 @@ func (bicr *APIBatchInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg // Merge things in from OS when not // found in request. - if mergedIpxeScript == nil { + // Templated iPXE uses server-side template resolution, so the OS's + // inline ipxeScript must not be copied into the request. + if mergedIpxeScript == nil && os.Type == cdbm.OperatingSystemTypeIPXE { mergedIpxeScript = os.IpxeScript bicr.IpxeScript = mergedIpxeScript } @@ -1152,18 +1156,20 @@ func (iur *APIInstanceUpdateRequest) ValidateAndSetOperatingSystemData(cfg *conf // Merge things in from OS and/or instance when not // found in request. if mergedIpxeScript == nil { - if iur.OperatingSystemID != nil { + if iur.OperatingSystemID != nil && os.Type == cdbm.OperatingSystemTypeIPXE { // If no script was sent in the request, and the - // request is changing the operating system, + // request is changing to a raw iPXE operating system, // give it precedence. // I.e., the caller has said, "Switch the base OS," // but has not sent in any override for iPXE script, // so the script of the base OS will be used. + // Templated iPXE uses server-side template resolution, + // so we must not copy the OS's inline ipxeScript. mergedIpxeScript = os.IpxeScript // Set it so the DB gets updated. iur.IpxeScript = mergedIpxeScript - } else { + } else if iur.OperatingSystemID == nil { // If no script was sent in the request AND no // change in base OS is being requested, then // use the existing iPXE script of the instance. diff --git a/api/pkg/api/model/instance_test.go b/api/pkg/api/model/instance_test.go index 12664b526..3e3d282b1 100644 --- a/api/pkg/api/model/instance_test.go +++ b/api/pkg/api/model/instance_test.go @@ -1106,6 +1106,38 @@ func TestAPIInstanceCreateRequest_ValidateAndSetOperatingSystemData(t *testing.T imageOSDeactivated.IsActive = false imageOSDeactivated.ID = uuid.New() + templatedIpxeOs := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "templated-ipxe-os", + IpxeScript: nil, + IpxeTemplateId: cdb.GetStrPtr("my-template"), + UserData: nil, + PhoneHomeEnabled: false, + IsActive: true, + Status: cdbm.OperatingSystemStatusReady, + AllowOverride: false, + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + CreatedBy: uuid.New(), + } + + templatedIpxeOsDeactivated := new(cdbm.OperatingSystem) + *templatedIpxeOsDeactivated = *templatedIpxeOs + templatedIpxeOsDeactivated.IsActive = false + templatedIpxeOsDeactivated.ID = uuid.New() + + providerOwnedIpxeOs := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "provider-ipxe-os", + IpxeTemplateId: cdb.GetStrPtr("provider-template"), + PhoneHomeEnabled: false, + IsActive: true, + Status: cdbm.OperatingSystemStatusReady, + AllowOverride: false, + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + InfrastructureProviderID: cdb.GetUUIDPtr(uuid.New()), + CreatedBy: uuid.New(), + } + tests := []struct { name string fields fields @@ -1387,6 +1419,97 @@ func TestAPIInstanceCreateRequest_ValidateAndSetOperatingSystemData(t *testing.T cfg: cfg1, os: os, }, + // ─── Templated iPXE OS instance creation ────────────────────────── + { + name: "templated iPXE os selected, no override, phone-home enabled, should succeed", + fields: fields{ + Name: "test-name", + Description: cdb.GetStrPtr("Test description"), + TenantID: uuid.NewString(), + InstanceTypeID: uuid.NewString(), + VpcID: uuid.NewString(), + OperatingSystemID: cdb.GetStrPtr(templatedIpxeOs.ID.String()), + PhoneHomeEnabled: cdb.GetBoolPtr(true), + }, + os: templatedIpxeOs, + cfg: cfg1, + wantErr: false, + }, + { + name: "templated iPXE os selected, iPXE script specified, should fail", + fields: fields{ + Name: "test-name", + Description: cdb.GetStrPtr("Test description"), + TenantID: uuid.NewString(), + InstanceTypeID: uuid.NewString(), + VpcID: uuid.NewString(), + OperatingSystemID: cdb.GetStrPtr(templatedIpxeOs.ID.String()), + IpxeScript: cdb.GetStrPtr("#!ipxe\nboot"), + }, + os: templatedIpxeOs, + cfg: cfg1, + wantErr: true, + }, + { + name: "deactivated templated iPXE os selected, should fail", + fields: fields{ + Name: "test-name", + Description: cdb.GetStrPtr("Test description"), + TenantID: uuid.NewString(), + InstanceTypeID: uuid.NewString(), + VpcID: uuid.NewString(), + OperatingSystemID: cdb.GetStrPtr(templatedIpxeOsDeactivated.ID.String()), + }, + os: templatedIpxeOsDeactivated, + cfg: cfg1, + wantErr: true, + }, + { + name: "templated iPXE os, AlwaysBootWithCustomIpxe, should fail", + fields: fields{ + Name: "test-name", + Description: cdb.GetStrPtr("Test description"), + TenantID: uuid.NewString(), + InstanceTypeID: uuid.NewString(), + VpcID: uuid.NewString(), + OperatingSystemID: cdb.GetStrPtr(templatedIpxeOs.ID.String()), + AlwaysBootWithCustomIpxe: cdb.GetBoolPtr(true), + }, + os: templatedIpxeOs, + cfg: cfg1, + wantErr: true, + }, + // ─── Provider-owned OS instance creation ────────────────────────── + { + name: "provider-owned templated iPXE os, no override, phone-home enabled, should succeed", + fields: fields{ + Name: "test-name", + Description: cdb.GetStrPtr("Test description"), + TenantID: uuid.NewString(), + InstanceTypeID: uuid.NewString(), + VpcID: uuid.NewString(), + OperatingSystemID: cdb.GetStrPtr(providerOwnedIpxeOs.ID.String()), + PhoneHomeEnabled: cdb.GetBoolPtr(true), + }, + os: providerOwnedIpxeOs, + cfg: cfg1, + wantErr: false, + }, + { + name: "provider-owned templated iPXE os, user-data specified but no override, should fail", + fields: fields{ + Name: "test-name", + Description: cdb.GetStrPtr("Test description"), + TenantID: uuid.NewString(), + InstanceTypeID: uuid.NewString(), + VpcID: uuid.NewString(), + OperatingSystemID: cdb.GetStrPtr(providerOwnedIpxeOs.ID.String()), + UserData: cdb.GetStrPtr("custom user data"), + }, + os: providerOwnedIpxeOs, + cfg: cfg1, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/api/pkg/api/model/ipxetemplate.go b/api/pkg/api/model/ipxetemplate.go new file mode 100644 index 000000000..c6736a34a --- /dev/null +++ b/api/pkg/api/model/ipxetemplate.go @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "time" + + cdbm "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/model" +) + +// APIIpxeTemplate is the data structure to capture the API representation of an iPXE template. +// +// iPXE templates are global in REST and identified by the stable UUID assigned by core +// (`ID`). Per-site availability is tracked separately and not surfaced in this payload. +type APIIpxeTemplate struct { + // ID is the stable template UUID assigned by core, identical between core and REST + ID string `json:"id"` + // Name is the globally unique template name (e.g. "ubuntu-autoinstall", "kernel-initrd") + Name string `json:"name"` + // Template is the raw iPXE script content + Template string `json:"template"` + // RequiredParams lists the parameters that must be provided to render the template + RequiredParams []string `json:"requiredParams"` + // ReservedParams lists the parameters that are reserved by the template and cannot be user-supplied + ReservedParams []string `json:"reservedParams"` + // RequiredArtifacts lists the artifact names (e.g. "kernel", "initrd") required for the template + RequiredArtifacts []string `json:"requiredArtifacts"` + // Scope indicates the visibility of this template: "Internal" or "Public" + Scope string `json:"scope"` + // Created is the date and time the entity was created in this system + Created time.Time `json:"created"` + // Updated is the date and time the entity was last updated in this system + Updated time.Time `json:"updated"` +} + +// NewAPIIpxeTemplate accepts a DB layer IpxeTemplate object and returns an API layer object +func NewAPIIpxeTemplate(dbTemplate *cdbm.IpxeTemplate) *APIIpxeTemplate { + if dbTemplate == nil { + return nil + } + + requiredParams := dbTemplate.RequiredParams + if requiredParams == nil { + requiredParams = []string{} + } + + reservedParams := dbTemplate.ReservedParams + if reservedParams == nil { + reservedParams = []string{} + } + + requiredArtifacts := dbTemplate.RequiredArtifacts + if requiredArtifacts == nil { + requiredArtifacts = []string{} + } + + return &APIIpxeTemplate{ + ID: dbTemplate.ID.String(), + Name: dbTemplate.Name, + Template: dbTemplate.Template, + RequiredParams: requiredParams, + ReservedParams: reservedParams, + RequiredArtifacts: requiredArtifacts, + Scope: dbTemplate.Scope, + Created: dbTemplate.Created, + Updated: dbTemplate.Updated, + } +} diff --git a/api/pkg/api/model/operatingsystem.go b/api/pkg/api/model/operatingsystem.go index 3378c23c7..23c1796d0 100644 --- a/api/pkg/api/model/operatingsystem.go +++ b/api/pkg/api/model/operatingsystem.go @@ -19,13 +19,17 @@ package model import ( "errors" + "fmt" + "strings" "time" "github.com/NVIDIA/ncx-infra-controller-rest/api/pkg/api/model/util" cdb "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" cdbm "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/model" + cwssaws "github.com/NVIDIA/ncx-infra-controller-rest/workflow-schema/schema/site-agent/workflows/v1" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/go-ozzo/ozzo-validation/v4/is" + validationis "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/google/uuid" "gopkg.in/yaml.v3" ) @@ -77,38 +81,60 @@ type APIOperatingSystemCreateRequest struct { AllowOverride bool `json:"allowOverride"` // EnableBlockStorage indicates whether the Operating System image will be stored remotely via block storage EnableBlockStorage bool `json:"enableBlockStorage"` + // IpxeTemplateId is the name of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, …) for the iPXE OS definition + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and carbide-core. + // Allowed values: "Global" (rest→core, all sites), "Limited" (rest→core, specific sites + // listed in siteIds). Required for Templated iPXE OS. For raw iPXE OS, only "Global" + // or unspecified is accepted; the handler always normalizes raw iPXE to "Global". + // Rejected for Image OS (validateImageOS). + Scope *string `json:"scope"` } // Validate ensure the values passed in request are acceptable func (oscr APIOperatingSystemCreateRequest) Validate() error { - var err error - err = validation.ValidateStruct(&oscr, + err := validation.ValidateStruct(&oscr, validation.Field(&oscr.Name, validation.Required.Error(validationErrorStringLength), validation.By(util.ValidateNameCharacters), validation.Length(2, 256).Error(validationErrorStringLength)), + // TODO: InfrastructureProviderID as parameter is deprecated and will need to be removed by 2026-10-01. validation.Field(&oscr.InfrastructureProviderID, - // infrastructure provider id must be nil validation.Nil.Error(validationErrorInfrastructureProviderIDExpectNil)), + validation.Field(&oscr.TenantID, + validation.When(oscr.TenantID != nil, validationis.UUID.Error(validationErrorInvalidUUID))), ) if err != nil { return err } - // Make sure siteIds only required in case of image is OS based - if oscr.IpxeScript != nil && len(oscr.SiteIDs) > 0 { + if oscr.IpxeTemplateId != nil && strings.TrimSpace(*oscr.IpxeTemplateId) == "" { return validation.Errors{ - "siteIds": errors.New("cannot be specified for iPXE based Operating Systems"), + "ipxeTemplateId": errors.New("must not be empty"), } } - if oscr.IpxeScript != nil && oscr.ImageURL != nil { + if oscr.IpxeScript != nil && oscr.IpxeTemplateId != nil { return validation.Errors{ - "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), + "ipxeTemplateId": errors.New("ipxeScript and ipxeTemplateId are mutually exclusive"), + } + } + + osType := oscr.GetOperatingSystemType() + + if osType == cdbm.OperatingSystemTypeImage && oscr.ImageURL == nil { + return validation.Errors{ + validationCommonErrorField: errors.New("one of imageURL, ipxeScript, or ipxeTemplateId must be specified"), } - } else if oscr.IpxeScript == nil && oscr.ImageURL == nil { + } + + if cdbm.IsIPXEType(osType) && oscr.ImageURL != nil { return validation.Errors{ - validationCommonErrorField: errors.New("either imageURL or ipxeScript must be specified"), + "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), } } @@ -118,67 +144,175 @@ func (oscr APIOperatingSystemCreateRequest) Validate() error { } } - if oscr.ImageURL != nil { - err = validation.ValidateStruct(&oscr, - validation.Field(&oscr.ImageURL, is.URL), - validation.Field(&oscr.ImageSHA, - validation.Required.Error(validationErrorValueRequired), - validation.When(oscr.ImageSHA != nil, validation.Match(util.ShaHashRegex).Error(errMsgInvalidImageSHA))), - validation.Field(&oscr.ImageAuthType, - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken), - validation.Required.Error("imageAuthType cannot be specified if imageAuthToken is not specified")), - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)), - validation.In(cdbm.OperatingSystemAuthTypeBasic, cdbm.OperatingSystemAuthTypeBearer).Error("imageAuthType must be Basic or Bearer")), - ), - validation.Field(&oscr.ImageAuthToken, - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthType), validation.Required.Error("imageAuthType must be specified when imageAuthToken is specified"))), - validation.Field(&oscr.ImageDisk, - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageDisk)), validation.Match(util.DiskImagePathRegex).Error(errMsgInvalidImageDiskPath))), - validation.Field(&oscr.RootFsID, - validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel), validation.Required.Error(errMsgExactlyOneRootFsField)), - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel)), validation.Empty.Error(errMsgExactlyOneRootFsField))), - validation.Field(&oscr.RootFsLabel, - validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsID), validation.Required.Error(errMsgExactlyOneRootFsField)), - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsID)), validation.Empty.Error(errMsgExactlyOneRootFsField))), - ) - if len(oscr.SiteIDs) == 0 { - return validation.Errors{ - "siteIds": errors.New("must be specified for image based Operating Systems"), - } - } else if len(oscr.SiteIDs) > 1 { - return validation.Errors{ - "siteIds": errors.New("must specify a single Site ID. Creating Image based Operating System on more than one Site is not supported"), - } + switch osType { + case cdbm.OperatingSystemTypeIPXE: + return oscr.validateRawIpxeOS() + case cdbm.OperatingSystemTypeTemplatedIPXE: + return oscr.validateTemplatedIpxeOS() + case cdbm.OperatingSystemTypeImage: + return oscr.validateImageOS() + } + + return nil +} + +func (oscr APIOperatingSystemCreateRequest) validateImageOS() error { + if len(oscr.IpxeTemplateParameters) > 0 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for Image based Operating Systems"), + } + } + if len(oscr.IpxeTemplateArtifacts) > 0 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for Image based Operating Systems"), } - } else { - err = validation.ValidateStruct(&oscr, - validation.Field(&oscr.SiteIDs, - validation.Nil.Error("siteIds cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageSHA, - validation.Nil.Error("imageSHA cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageAuthType, - validation.Nil.Error("imageAuthType cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageAuthToken, - validation.Nil.Error("imageAuthToken cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageDisk, - validation.Nil.Error("imageDisk cannot be specified if imageURL is not specified")), - validation.Field(&oscr.RootFsID, - validation.Nil.Error("rootFsID cannot be specified if imageURL is not specified")), - validation.Field(&oscr.RootFsLabel, - validation.Nil.Error("rootFsLabel cannot be specified if imageURL is not specified")), - ) } - if oscr.IpxeScript != nil { - err = validation.ValidateStruct(&oscr, - validation.Field(&oscr.IpxeScript, - validation.Required.Error(validationErrorValueRequired)), - validation.Field(&oscr.EnableBlockStorage, - validation.Empty.Error("enableBlockStorage must be false if ipxeScript is specified")), - ) + err := validation.ValidateStruct(&oscr, + validation.Field(&oscr.ImageURL, is.URL), + validation.Field(&oscr.ImageSHA, + validation.Required.Error(validationErrorValueRequired), + validation.When(oscr.ImageSHA != nil, validation.Match(util.ShaHashRegex).Error(errMsgInvalidImageSHA))), + validation.Field(&oscr.ImageAuthType, + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken), + validation.Required.Error("imageAuthType cannot be specified if imageAuthToken is not specified")), + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)), + validation.In(cdbm.OperatingSystemAuthTypeBasic, cdbm.OperatingSystemAuthTypeBearer).Error("imageAuthType must be Basic or Bearer")), + ), + validation.Field(&oscr.ImageAuthToken, + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthType), validation.Required.Error("imageAuthType must be specified when imageAuthToken is specified"))), + validation.Field(&oscr.ImageDisk, + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageDisk)), validation.Match(util.DiskImagePathRegex).Error(errMsgInvalidImageDiskPath))), + validation.Field(&oscr.RootFsID, + validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel), validation.Required.Error(errMsgExactlyOneRootFsField)), + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel)), validation.Empty.Error(errMsgExactlyOneRootFsField))), + validation.Field(&oscr.RootFsLabel, + validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsID), validation.Required.Error(errMsgExactlyOneRootFsField)), + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsID)), validation.Empty.Error(errMsgExactlyOneRootFsField))), + ) + if err != nil { + return err + } + + if len(oscr.SiteIDs) == 0 { + return validation.Errors{ + "siteIds": errors.New("must be specified for image based Operating Systems"), + } + } + if len(oscr.SiteIDs) > 1 { + return validation.Errors{ + "siteIds": errors.New("must specify a single Site ID. Creating Image based Operating System on more than one Site is not supported"), + } + } + + if oscr.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope can only be specified for Templated iPXE Operating Systems"), + } + } + + return nil +} + +// rejectImageFields validates that image-specific fields are not set. +func (oscr APIOperatingSystemCreateRequest) rejectImageFields() error { + return validation.ValidateStruct(&oscr, + validation.Field(&oscr.ImageSHA, + validation.Nil.Error("imageSHA cannot be specified if imageURL is not specified")), + validation.Field(&oscr.ImageAuthType, + validation.Nil.Error("imageAuthType cannot be specified if imageURL is not specified")), + validation.Field(&oscr.ImageAuthToken, + validation.Nil.Error("imageAuthToken cannot be specified if imageURL is not specified")), + validation.Field(&oscr.ImageDisk, + validation.Nil.Error("imageDisk cannot be specified if imageURL is not specified")), + validation.Field(&oscr.RootFsID, + validation.Nil.Error("rootFsID cannot be specified if imageURL is not specified")), + validation.Field(&oscr.RootFsLabel, + validation.Nil.Error("rootFsLabel cannot be specified if imageURL is not specified")), + ) +} + +func (oscr APIOperatingSystemCreateRequest) validateRawIpxeOS() error { + if err := oscr.rejectImageFields(); err != nil { + return err + } + + if err := validation.ValidateStruct(&oscr, + validation.Field(&oscr.IpxeScript, + validation.Required.Error(validationErrorValueRequired)), + ); err != nil { + return err + } + + if len(oscr.IpxeTemplateParameters) > 0 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for raw iPXE Operating Systems; use ipxeTemplateId for template-based OS"), + } + } + if len(oscr.IpxeTemplateArtifacts) > 0 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for raw iPXE Operating Systems; use ipxeTemplateId for template-based OS"), + } + } + + if oscr.Scope != nil && *oscr.Scope != cdbm.OperatingSystemScopeGlobal { + return validation.Errors{ + "scope": fmt.Errorf("scope must be %q or unspecified for raw iPXE Operating Systems", cdbm.OperatingSystemScopeGlobal), + } + } + + if len(oscr.SiteIDs) > 0 { + return validation.Errors{ + "siteIds": errors.New("siteIds cannot be specified for raw iPXE Operating Systems"), + } + } + + return nil +} + +func (oscr APIOperatingSystemCreateRequest) validateTemplatedIpxeOS() error { + if err := oscr.rejectImageFields(); err != nil { + return err + } + + if oscr.Scope == nil { + return validation.Errors{ + "scope": errors.New("scope is required for Templated iPXE Operating Systems"), + } + } + + switch *oscr.Scope { + case cdbm.OperatingSystemScopeGlobal, cdbm.OperatingSystemScopeLimited: + // valid + case cdbm.OperatingSystemScopeLocal: + return validation.Errors{ + "scope": errors.New("scope 'Local' cannot be specified at creation; Local Operating Systems are created in carbide-core"), + } + default: + return validation.Errors{ + "scope": errors.New("scope must be one of 'Global' or 'Limited'"), + } + } + + if len(oscr.SiteIDs) > 0 && *oscr.Scope != cdbm.OperatingSystemScopeLimited { + return validation.Errors{ + "siteIds": errors.New("siteIds can only be specified for Templated iPXE Operating Systems with scope 'Limited'"), + } + } + if *oscr.Scope == cdbm.OperatingSystemScopeLimited && len(oscr.SiteIDs) == 0 { + return validation.Errors{ + "siteIds": errors.New("at least one siteId must be specified when scope is 'Limited'"), + } } - return err + if err := validateIpxeTemplateParameters(oscr.IpxeTemplateParameters); err != nil { + return err + } + if err := validateIpxeTemplateArtifacts(oscr.IpxeTemplateArtifacts); err != nil { + return err + } + + return nil } func (oscr *APIOperatingSystemCreateRequest) ValidateAndSetUserData(phonehomeUrl string) error { @@ -272,6 +406,14 @@ type APIOperatingSystemUpdateRequest struct { IsActive *bool `json:"isActive"` // DeactivationNote is the deactivation note if any DeactivationNote *string `json:"deactivationNote"` + // IpxeTemplateId is the name of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters *[]cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, …) for the iPXE OS definition + IpxeTemplateArtifacts *[]cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope is immutable after creation. If provided, the request is rejected. + Scope *string `json:"scope"` } // Validate ensure the values passed in request are acceptable @@ -307,21 +449,45 @@ func (osur APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.OperatingS } } - if osur.IpxeScript != nil && osur.ImageURL != nil { + if osur.IpxeScript != nil && osur.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("ipxeScript and ipxeTemplateId are mutually exclusive"), + } + } + + if osur.IpxeTemplateId != nil && strings.TrimSpace(*osur.IpxeTemplateId) == "" { + return validation.Errors{ + "ipxeTemplateId": errors.New("must not be empty"), + } + } + + if (osur.IpxeScript != nil || osur.IpxeTemplateId != nil) && osur.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), } } - // verify if os created with ipxe script, if yes reject the update if imageURL provided - if existingOS.Type == cdbm.OperatingSystemTypeIPXE && osur.ImageURL != nil { + // Reject cross-type field assignments (iPXE → Templated iPXE → Image). + if cdbm.IsIPXEType(existingOS.Type) && osur.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("unable to set image URL for iPXE based Operating System"), } + } else if existingOS.Type == cdbm.OperatingSystemTypeIPXE && osur.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("unable to set iPXE template for raw iPXE Operating System"), + } + } else if existingOS.Type == cdbm.OperatingSystemTypeTemplatedIPXE && osur.IpxeScript != nil { + return validation.Errors{ + "ipxeScript": errors.New("unable to set iPXE script for templated iPXE Operating System"), + } } else if existingOS.Type == cdbm.OperatingSystemTypeImage && osur.IpxeScript != nil { return validation.Errors{ "ipxeScript": errors.New("unable to set iPXE script for image based Operating System"), } + } else if existingOS.Type == cdbm.OperatingSystemTypeImage && osur.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("unable to set iPXE template for image based Operating System"), + } } isImageBased := existingOS.Type == cdbm.OperatingSystemTypeImage @@ -387,7 +553,53 @@ func (osur APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.OperatingS validation.Required.Error(validationErrorValueRequired)), ) } - return err + + if osur.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope cannot be changed after creation"), + } + } + + if err != nil { + return err + } + + isImageBased2 := existingOS.Type == cdbm.OperatingSystemTypeImage + isRawIpxe := existingOS.Type == cdbm.OperatingSystemTypeIPXE + + if osur.IpxeTemplateParameters != nil { + if isImageBased2 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for Image based Operating Systems"), + } + } + if isRawIpxe { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for raw iPXE Operating Systems"), + } + } + if verr := validateIpxeTemplateParameters(*osur.IpxeTemplateParameters); verr != nil { + return verr + } + } + + if osur.IpxeTemplateArtifacts != nil { + if isImageBased2 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for Image based Operating Systems"), + } + } + if isRawIpxe { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for raw iPXE Operating Systems"), + } + } + if verr := validateIpxeTemplateArtifacts(*osur.IpxeTemplateArtifacts); verr != nil { + return verr + } + } + + return nil } func (osur *APIOperatingSystemUpdateRequest) ValidateAndSetUserData(phonehomeUrl string, existingOS *cdbm.OperatingSystem) error { @@ -531,8 +743,17 @@ type APIOperatingSystem struct { RootFsID *string `json:"rootFsId"` // RootFsLabel is root fs id for the Operating System image type RootFsLabel *string `json:"rootFsLabel"` - // IpxeScript is the ipxe ocript for the Operating System + // IpxeScript is the ipxe script for the Operating System IpxeScript *string `json:"ipxeScript"` + // IpxeTemplateId is the name of the iPXE template used by this Operating System + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters passed to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, …) for the iPXE OS definition + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and carbide-core. + // One of "Local" (default), "Global", or "Limited". Only valid for Templated iPXE OSes. + Scope *string `json:"scope"` // PhoneHomeEnabled is an attribute which is specified by user if Operating System needs to be enabled for phone home or not PhoneHomeEnabled bool `json:"phoneHomeEnabled"` // UserData is the user data for the Operating System @@ -562,28 +783,32 @@ type APIOperatingSystem struct { // NewAPIOperatingSystem accepts a DB layer objects and returns an API layer object func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail, ossas []cdbm.OperatingSystemSiteAssociation, sttsmap map[uuid.UUID]*cdbm.TenantSite) *APIOperatingSystem { apiOperatingSystem := APIOperatingSystem{ - ID: dbOS.ID.String(), - Name: dbOS.Name, - Description: dbOS.Description, - Type: &dbOS.Type, - ImageURL: dbOS.ImageURL, - ImageSHA: dbOS.ImageSHA, - ImageAuthType: dbOS.ImageAuthType, - ImageAuthToken: dbOS.ImageAuthToken, - ImageDisk: dbOS.ImageDisk, - RootFsID: dbOS.RootFsID, - RootFsLabel: dbOS.RootFsLabel, - IpxeScript: dbOS.IpxeScript, - PhoneHomeEnabled: dbOS.PhoneHomeEnabled, - UserData: dbOS.UserData, - IsCloudInit: dbOS.IsCloudInit, - AllowOverride: dbOS.AllowOverride, - EnableBlockStorage: dbOS.EnableBlockStorage, - IsActive: dbOS.IsActive, - DeactivationNote: dbOS.DeactivationNote, - Status: dbOS.Status, - Created: dbOS.Created, - Updated: dbOS.Updated, + ID: dbOS.ID.String(), + Name: dbOS.Name, + Description: dbOS.Description, + Type: &dbOS.Type, + ImageURL: dbOS.ImageURL, + ImageSHA: dbOS.ImageSHA, + ImageAuthType: dbOS.ImageAuthType, + ImageAuthToken: dbOS.ImageAuthToken, + ImageDisk: dbOS.ImageDisk, + RootFsID: dbOS.RootFsID, + RootFsLabel: dbOS.RootFsLabel, + IpxeScript: dbOS.IpxeScript, + IpxeTemplateId: dbOS.IpxeTemplateId, + IpxeTemplateParameters: dbOS.IpxeTemplateParameters, + IpxeTemplateArtifacts: dbOS.IpxeTemplateArtifacts, + Scope: dbOS.IpxeOsScope, + PhoneHomeEnabled: dbOS.PhoneHomeEnabled, + UserData: dbOS.UserData, + IsCloudInit: dbOS.IsCloudInit, + AllowOverride: dbOS.AllowOverride, + EnableBlockStorage: dbOS.EnableBlockStorage, + IsActive: dbOS.IsActive, + DeactivationNote: dbOS.DeactivationNote, + Status: dbOS.Status, + Created: dbOS.Created, + Updated: dbOS.Updated, } if dbOS.InfrastructureProviderID != nil { apiOperatingSystem.InfrastructureProviderID = cdb.GetStrPtr(dbOS.InfrastructureProviderID.String()) @@ -610,6 +835,77 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail return &apiOperatingSystem } +// GetOperatingSystemType returns the OperatingSystem type inferred from the +// create request's source fields (`IpxeScript`, `IpxeTemplateId`, or neither). +func (oscr APIOperatingSystemCreateRequest) GetOperatingSystemType() string { + if oscr.IpxeScript != nil { + return cdbm.OperatingSystemTypeIPXE + } + if oscr.IpxeTemplateId != nil { + return cdbm.OperatingSystemTypeTemplatedIPXE + } + return cdbm.OperatingSystemTypeImage +} + +// validCacheStrategies is the set of accepted CacheStrategy string values. +var validCacheStrategies = func() map[string]struct{} { + m := make(map[string]struct{}, len(cwssaws.IpxeTemplateArtifactCacheStrategy_value)) + for name := range cwssaws.IpxeTemplateArtifactCacheStrategy_value { + m[name] = struct{}{} + } + return m +}() + +func validateIpxeTemplateParameters(params []cdbm.OperatingSystemIpxeParameter) error { + for i, p := range params { + if strings.TrimSpace(p.Name) == "" { + return validation.Errors{ + "ipxeTemplateParameters": fmt.Errorf("entry %d: name is required", i), + } + } + } + return nil +} + +func validateIpxeTemplateArtifacts(artifacts []cdbm.OperatingSystemIpxeArtifact) error { + for i, a := range artifacts { + if strings.TrimSpace(a.Name) == "" { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d: name is required", i), + } + } + if strings.TrimSpace(a.URL) == "" { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): url is required", i, a.Name), + } + } + if _, ok := validCacheStrategies[a.CacheStrategy]; !ok { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): cacheStrategy must be one of CACHE_AS_NEEDED, LOCAL_ONLY, CACHED_ONLY, REMOTE_ONLY", i, a.Name), + } + } + if a.AuthType != nil && *a.AuthType != "" { + at := *a.AuthType + if at != cdbm.OperatingSystemAuthTypeBasic && at != cdbm.OperatingSystemAuthTypeBearer { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authType must be Basic or Bearer", i, a.Name), + } + } + if a.AuthToken == nil || *a.AuthToken == "" { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authToken is required when authType is specified", i, a.Name), + } + } + } + if a.AuthToken != nil && *a.AuthToken != "" && (a.AuthType == nil || *a.AuthType == "") { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authType must be specified when authToken is provided", i, a.Name), + } + } + } + return nil +} + // APIOperatingSystemSummary is the data structure to capture API summary of an OperatingSystem type APIOperatingSystemSummary struct { // ID of the OperatingSystem diff --git a/api/pkg/api/model/operatingsystem_test.go b/api/pkg/api/model/operatingsystem_test.go index e5a8d68a3..752c7a3e6 100644 --- a/api/pkg/api/model/operatingsystem_test.go +++ b/api/pkg/api/model/operatingsystem_test.go @@ -195,6 +195,277 @@ func TestAPIOperatingSystemCreateRequest_Validate(t *testing.T) { obj: APIOperatingSystemCreateRequest{Name: "abc", TenantID: cdb.GetStrPtr(uuid.New().String()), ImageURL: cdb.GetStrPtr("http://iso.net/iso"), SiteIDs: []string{uuid.NewString()}, ImageSHA: cdb.GetStrPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), RootFsID: cdb.GetStrPtr("666c2eee-193d-42db-a490-4c444342bd4e"), IsCloudInit: true, AllowOverride: false, ImageDisk: cdb.GetStrPtr(""), ImageAuthType: cdb.GetStrPtr(""), ImageAuthToken: cdb.GetStrPtr("")}, expectErr: false, }, + // ─── Templated iPXE OS validation ───────────────────────────────── + { + desc: "ok when valid templated iPXE with global scope", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-global", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: false, + }, + { + desc: "ok when valid templated iPXE with limited scope and siteIds", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-limited", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeLimited), + SiteIDs: []string{uuid.NewString()}, + }, + expectErr: false, + }, + { + desc: "error when templated iPXE missing scope", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-no-scope", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with scope Local", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-local", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeLocal), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with invalid scope", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-scope", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr("InvalidScope"), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with limited scope but no siteIds", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-limited-no-sites", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeLimited), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with global scope and siteIds", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-global-with-sites", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + SiteIDs: []string{uuid.NewString()}, + }, + expectErr: true, + }, + { + desc: "error when both ipxeScript and ipxeTemplateId specified", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-conflict", + IpxeScript: cdb.GetStrPtr("ipxe"), + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when ipxeTemplateId is empty string", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-empty-id", + IpxeTemplateId: cdb.GetStrPtr(""), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when ipxeTemplateId is whitespace only", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-ws-id", + IpxeTemplateId: cdb.GetStrPtr(" "), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE has image fields", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-image-fields", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + ImageSHA: cdb.GetStrPtr("abc123"), + }, + expectErr: true, + }, + { + desc: "error when template parameter has empty name", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-param", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "", Value: "val"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has empty name", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-art-name", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has empty URL", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-art-url", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has invalid cacheStrategy", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-art-cache", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "INVALID_STRATEGY"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has authType without authToken", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-auth-notoken", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthType: cdb.GetStrPtr("Bearer")}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has authToken without authType", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-token-notype", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthToken: cdb.GetStrPtr("secret")}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has invalid authType", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-bad-authtype", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthType: cdb.GetStrPtr("VAPID"), AuthToken: cdb.GetStrPtr("secret")}, + }, + }, + expectErr: true, + }, + { + desc: "ok when template artifact has valid auth pair", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-valid-auth", + IpxeTemplateId: cdb.GetStrPtr("my-template"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthType: cdb.GetStrPtr("Bearer"), AuthToken: cdb.GetStrPtr("secret")}, + }, + }, + expectErr: false, + }, + { + desc: "raw iPXE with explicit Global scope is allowed", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-global-scope", + IpxeScript: cdb.GetStrPtr("ipxe-script"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: false, + }, + { + desc: "error when raw iPXE has non-Global scope specified", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-limited-scope", + IpxeScript: cdb.GetStrPtr("ipxe-script"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeLimited), + }, + expectErr: true, + }, + { + desc: "error when raw iPXE has template parameters", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-params", + IpxeScript: cdb.GetStrPtr("ipxe-script"), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "k", Value: "v"}, + }, + }, + expectErr: true, + }, + { + desc: "error when raw iPXE has template artifacts", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-arts", + IpxeScript: cdb.GetStrPtr("ipxe-script"), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "k", URL: "http://example.com", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: true, + }, + { + desc: "error when image OS has scope specified", + obj: APIOperatingSystemCreateRequest{ + Name: "image-os-with-scope", + ImageURL: cdb.GetStrPtr("http://iso.net/iso"), + SiteIDs: []string{uuid.NewString()}, + ImageSHA: cdb.GetStrPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), + RootFsID: cdb.GetStrPtr("666c2eee-193d-42db-a490-4c444342bd4e"), + Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when image OS has template parameters", + obj: APIOperatingSystemCreateRequest{ + Name: "image-os-with-params", + ImageURL: cdb.GetStrPtr("http://iso.net/iso"), + SiteIDs: []string{uuid.NewString()}, + ImageSHA: cdb.GetStrPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), + RootFsID: cdb.GetStrPtr("666c2eee-193d-42db-a490-4c444342bd4e"), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "k", Value: "v"}, + }, + }, + expectErr: true, + }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { @@ -228,6 +499,15 @@ func TestAPIOperatingSystemUpdateRequest_Validate(t *testing.T) { CreatedBy: uuid.New(), } + existingTemplatedIpxeOS := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "tmpl-os", + Status: cdbm.OperatingSystemStatusReady, + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + IsActive: true, + CreatedBy: uuid.New(), + } + existingImageBasedOSWithFSLabel := &cdbm.OperatingSystem{ ID: uuid.New(), Name: "abc", @@ -353,6 +633,89 @@ func TestAPIOperatingSystemUpdateRequest_Validate(t *testing.T) { obj: APIOperatingSystemUpdateRequest{Name: cdb.GetStrPtr("ab"), ImageURL: cdb.GetStrPtr("http://iso.net/iso"), ImageSHA: cdb.GetStrPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), RootFsID: cdb.GetStrPtr("666c2eee-193d-42db-a490-4c444342bd4e"), ImageDisk: cdb.GetStrPtr(""), ImageAuthType: cdb.GetStrPtr(""), ImageAuthToken: cdb.GetStrPtr("")}, expectErr: false, }, + // ─── Templated iPXE update validation ───────────────────────────── + { + desc: "error when scope is specified on update", + obj: APIOperatingSystemUpdateRequest{Name: cdb.GetStrPtr("updated-tmpl"), Scope: cdb.GetStrPtr(cdbm.OperatingSystemScopeGlobal)}, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateParameters specified for raw iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "k", Value: "v"}}}, + existingOS: existingIpxeBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateArtifacts specified for raw iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}}}, + existingOS: existingIpxeBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateParameters specified for image OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "k", Value: "v"}}}, + existingOS: existingImageBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateArtifacts specified for image OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}}}, + existingOS: existingImageBasedOS, + expectErr: true, + }, + { + desc: "ok when ipxeTemplateParameters updated for templated iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "kernel_params", Value: "ip=dhcp"}}}, + existingOS: existingTemplatedIpxeOS, + expectErr: false, + }, + { + desc: "ok when ipxeTemplateArtifacts updated for templated iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}}}, + existingOS: existingTemplatedIpxeOS, + expectErr: false, + }, + { + desc: "error when ipxeTemplateId set on image OS update", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateId: cdb.GetStrPtr("my-template")}, + existingOS: existingImageBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateId set on raw iPXE OS update", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateId: cdb.GetStrPtr("my-template")}, + existingOS: existingIpxeBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeScript set on templated iPXE OS update", + obj: APIOperatingSystemUpdateRequest{IpxeScript: cdb.GetStrPtr("chain --autofree https://boot.example.com")}, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when ipxeScript and ipxeTemplateId both on update", + obj: APIOperatingSystemUpdateRequest{IpxeScript: cdb.GetStrPtr("script"), IpxeTemplateId: cdb.GetStrPtr("tmpl")}, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when template parameter has blank name on update", + obj: APIOperatingSystemUpdateRequest{ + IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: " ", Value: "v"}}, + }, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when template artifact has invalid cacheStrategy on update", + obj: APIOperatingSystemUpdateRequest{ + IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "k", URL: "http://example.com/k", CacheStrategy: "BAD"}}, + }, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, } for _, tc := range tests { diff --git a/api/pkg/api/routes.go b/api/pkg/api/routes.go index 8e9d91c94..ecdace85a 100644 --- a/api/pkg/api/routes.go +++ b/api/pkg/api/routes.go @@ -841,6 +841,17 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa Method: http.MethodGet, Handler: apiHandler.NewGetSkuHandler(dbSession, tc, cfg), }, + // iPXE Template endpoints + { + Path: apiPathPrefix + "/ipxe-template", + Method: http.MethodGet, + Handler: apiHandler.NewGetAllIpxeTemplateHandler(dbSession, tc, cfg), + }, + { + Path: apiPathPrefix + "/ipxe-template/:id", + Method: http.MethodGet, + Handler: apiHandler.NewGetIpxeTemplateHandler(dbSession, tc, cfg), + }, // Rack endpoints (RLA) { Path: apiPathPrefix + "/rack/task/:id", diff --git a/api/pkg/api/routes_test.go b/api/pkg/api/routes_test.go index f88dae68a..7f52a35e6 100644 --- a/api/pkg/api/routes_test.go +++ b/api/pkg/api/routes_test.go @@ -81,6 +81,7 @@ func TestNewAPIRoutes(t *testing.T) { "machine-validation": 11, "dpu-extension-service": 7, "sku": 2, + "ipxe-template": 2, "rack": 11, "tray": 8, "stats": 4, diff --git a/db/pkg/db/model/ipxetemplate.go b/db/pkg/db/model/ipxetemplate.go new file mode 100644 index 000000000..8199ec237 --- /dev/null +++ b/db/pkg/db/model/ipxetemplate.go @@ -0,0 +1,294 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "database/sql" + "time" + + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/paginator" + stracer "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/tracer" + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +const ( + // IpxeTemplateRelationName is the relation name for the IpxeTemplate model + IpxeTemplateRelationName = "IpxeTemplate" + // IpxeTemplateOrderByCreated is the field name for ordering by created timestamp + IpxeTemplateOrderByCreated = "created" + // ipxeTemplateOrderByUpdated is the field name for ordering by updated timestamp + ipxeTemplateOrderByUpdated = "updated" + // IpxeTemplateOrderByName is the field name for ordering by name + IpxeTemplateOrderByName = "name" + // IpxeTemplateOrderByDefault is the default field for ordering + IpxeTemplateOrderByDefault = IpxeTemplateOrderByName + + // IpxeTemplateScopeInternal represents an internal-only template + IpxeTemplateScopeInternal = "Internal" + // IpxeTemplateScopePublic represents a public template + IpxeTemplateScopePublic = "Public" +) + +var ( + // IpxeTemplateOrderByFields is a list of valid order by fields for the IpxeTemplate model + IpxeTemplateOrderByFields = []string{IpxeTemplateOrderByCreated, ipxeTemplateOrderByUpdated, IpxeTemplateOrderByName} + // IpxeTemplateRelatedEntities is a list of valid relation by fields for the IpxeTemplate model. + // Per-site availability is tracked via IpxeTemplateSiteAssociation, not via a direct site relation. + IpxeTemplateRelatedEntities = map[string]bool{} +) + +// IpxeTemplate represents an iPXE script template propagated from bare-metal-manager-core. +// The primary key `ID` is the template UUID assigned by core and is consistent across +// REST and core. Per-site availability is tracked via IpxeTemplateSiteAssociation rows. +type IpxeTemplate struct { + bun.BaseModel `bun:"table:ipxe_template,alias:ipxet"` + + ID uuid.UUID `bun:"id,pk,type:uuid"` + Name string `bun:"name,notnull,unique"` + Template string `bun:"template,notnull,default:''"` + RequiredParams []string `bun:"required_params,type:text[],default:'{}'"` + ReservedParams []string `bun:"reserved_params,type:text[],default:'{}'"` + RequiredArtifacts []string `bun:"required_artifacts,type:text[],default:'{}'"` + Scope string `bun:"scope,notnull"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` +} + +// IpxeTemplateCreateInput are input parameters for the Create method. +// `ID` must be supplied (it is the stable template UUID from core). +type IpxeTemplateCreateInput struct { + ID uuid.UUID + Name string + Template string + RequiredParams []string + ReservedParams []string + RequiredArtifacts []string + Scope string +} + +// IpxeTemplateUpdateInput are input parameters for the Update method +type IpxeTemplateUpdateInput struct { + IpxeTemplateID uuid.UUID + Name string + Template string + RequiredParams []string + ReservedParams []string + RequiredArtifacts []string + Scope string +} + +// IpxeTemplateFilterInput are input parameters for the filter/GetAll method. +// Note: only `Public`-scoped templates are ever propagated into REST (see the +// workflow activity `UpdateIpxeTemplatesInDB`), so there is no scope filter. +// +// IDs filters on the template's primary key (which equals core's TemplateID). +// Names filters on the unique template name. +type IpxeTemplateFilterInput struct { + IDs []uuid.UUID + Names []string +} + +var _ bun.BeforeAppendModelHook = (*IpxeTemplate)(nil) + +// BeforeAppendModel is a hook called before the model is appended to the query +func (it *IpxeTemplate) BeforeAppendModel(ctx context.Context, query bun.Query) error { + switch query.(type) { + case *bun.InsertQuery: + it.Created = db.GetCurTime() + it.Updated = db.GetCurTime() + case *bun.UpdateQuery: + it.Updated = db.GetCurTime() + } + return nil +} + +// IpxeTemplateDAO is an interface for interacting with the IpxeTemplate model +type IpxeTemplateDAO interface { + // Create inserts a new iPXE template row + Create(ctx context.Context, tx *db.Tx, input IpxeTemplateCreateInput) (*IpxeTemplate, error) + // Update updates an existing iPXE template row + Update(ctx context.Context, tx *db.Tx, input IpxeTemplateUpdateInput) (*IpxeTemplate, error) + // Delete removes an iPXE template row by ID + Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error + // GetAll returns all rows matching the filter and page inputs + GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateFilterInput, page paginator.PageInput) ([]IpxeTemplate, int, error) + // Get returns the row for the specified ID (which is the core template UUID) + Get(ctx context.Context, tx *db.Tx, id uuid.UUID) (*IpxeTemplate, error) +} + +// IpxeTemplateSQLDAO is an implementation of the IpxeTemplateDAO interface +type IpxeTemplateSQLDAO struct { + dbSession *db.Session + IpxeTemplateDAO + tracerSpan *stracer.TracerSpan +} + +// Create inserts a new IpxeTemplate from the given parameters +func (itd IpxeTemplateSQLDAO) Create(ctx context.Context, tx *db.Tx, input IpxeTemplateCreateInput) (*IpxeTemplate, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Create") + if span != nil { + defer span.End() + } + + it := &IpxeTemplate{ + ID: input.ID, + Name: input.Name, + Template: input.Template, + RequiredParams: input.RequiredParams, + ReservedParams: input.ReservedParams, + RequiredArtifacts: input.RequiredArtifacts, + Scope: input.Scope, + } + + _, err := db.GetIDB(tx, itd.dbSession).NewInsert().Model(it).Exec(ctx) + if err != nil { + return nil, err + } + + return itd.Get(ctx, tx, it.ID) +} + +// Get returns an IpxeTemplate by ID +// Returns db.ErrDoesNotExist if the record is not found +func (itd IpxeTemplateSQLDAO) Get(ctx context.Context, tx *db.Tx, id uuid.UUID) (*IpxeTemplate, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Get") + if span != nil { + defer span.End() + itd.tracerSpan.SetAttribute(span, "id", id) + } + + it := &IpxeTemplate{} + + err := db.GetIDB(tx, itd.dbSession).NewSelect().Model(it).Where("ipxet.id = ?", id).Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return it, nil +} + +// setQueryWithFilter populates the lookup query based on the specified filter +func (itd IpxeTemplateSQLDAO) setQueryWithFilter(filter IpxeTemplateFilterInput, query *bun.SelectQuery, span *stracer.CurrentContextSpan) (*bun.SelectQuery, error) { + if len(filter.IDs) > 0 { + query = query.Where("ipxet.id IN (?)", bun.In(filter.IDs)) + if span != nil { + itd.tracerSpan.SetAttribute(span, "ids", filter.IDs) + } + } + + if len(filter.Names) > 0 { + query = query.Where("ipxet.name IN (?)", bun.In(filter.Names)) + if span != nil { + itd.tracerSpan.SetAttribute(span, "names", filter.Names) + } + } + + return query, nil +} + +// GetAll returns all IpxeTemplates with optional filters +// If orderBy is nil, records are ordered by IpxeTemplateOrderByDefault in ascending order +func (itd IpxeTemplateSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateFilterInput, page paginator.PageInput) ([]IpxeTemplate, int, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.GetAll") + if span != nil { + defer span.End() + } + + templates := []IpxeTemplate{} + + if filter.IDs != nil && len(filter.IDs) == 0 { + return templates, 0, nil + } + + query := db.GetIDB(tx, itd.dbSession).NewSelect().Model(&templates) + + query, err := itd.setQueryWithFilter(filter, query, span) + if err != nil { + return templates, 0, err + } + + if page.OrderBy == nil { + page.OrderBy = paginator.NewDefaultOrderBy(IpxeTemplateOrderByDefault) + } + + pager, err := paginator.NewPaginator(ctx, query, page.Offset, page.Limit, page.OrderBy, IpxeTemplateOrderByFields) + if err != nil { + return nil, 0, err + } + + err = pager.Query.Limit(pager.Limit).Offset(pager.Offset).Scan(ctx) + if err != nil { + return nil, 0, err + } + + return templates, pager.Total, nil +} + +// Update updates specified fields of an existing IpxeTemplate +func (itd IpxeTemplateSQLDAO) Update(ctx context.Context, tx *db.Tx, input IpxeTemplateUpdateInput) (*IpxeTemplate, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Update") + if span != nil { + defer span.End() + itd.tracerSpan.SetAttribute(span, "id", input.IpxeTemplateID) + } + + it := &IpxeTemplate{ID: input.IpxeTemplateID} + updatedFields := []string{"name", "template", "required_params", "reserved_params", "required_artifacts", "scope", "updated"} + + it.Name = input.Name + it.Template = input.Template + it.RequiredParams = input.RequiredParams + it.ReservedParams = input.ReservedParams + it.RequiredArtifacts = input.RequiredArtifacts + it.Scope = input.Scope + + _, err := db.GetIDB(tx, itd.dbSession).NewUpdate().Model(it).Column(updatedFields...).Where("ipxet.id = ?", input.IpxeTemplateID).Exec(ctx) + if err != nil { + return nil, err + } + + return itd.Get(ctx, tx, it.ID) +} + +// Delete removes an IpxeTemplate by ID +func (itd IpxeTemplateSQLDAO) Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Delete") + if span != nil { + defer span.End() + itd.tracerSpan.SetAttribute(span, "id", id) + } + + it := &IpxeTemplate{ID: id} + + _, err := db.GetIDB(tx, itd.dbSession).NewDelete().Model(it).Where("id = ?", id).Exec(ctx) + return err +} + +// NewIpxeTemplateDAO returns a new IpxeTemplateDAO +func NewIpxeTemplateDAO(dbSession *db.Session) IpxeTemplateDAO { + return &IpxeTemplateSQLDAO{ + dbSession: dbSession, + tracerSpan: stracer.NewTracerSpan(), + } +} diff --git a/db/pkg/db/model/ipxetemplate_test.go b/db/pkg/db/model/ipxetemplate_test.go new file mode 100644 index 000000000..4860c548b --- /dev/null +++ b/db/pkg/db/model/ipxetemplate_test.go @@ -0,0 +1,276 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "testing" + + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/paginator" + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/util" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testIpxeTemplateSetupSchema(t *testing.T, dbSession *db.Session) { + ctx := context.Background() + require.Nil(t, dbSession.DB.ResetModel(ctx, (*IpxeTemplate)(nil))) + + // Add UNIQUE(name). This is applied by migration 20260306120000_ipxe_os_and_templates.go + // in production; tests use ResetModel so we add it here to match. + _, err := dbSession.DB.Exec("ALTER TABLE ipxe_template DROP CONSTRAINT IF EXISTS ipxe_template_name_key") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template ADD CONSTRAINT ipxe_template_name_key UNIQUE (name)") + require.Nil(t, err) +} + +func testIpxeTemplateInitDB(t *testing.T) *db.Session { + return util.GetTestDBSession(t, false) +} + +func testIpxeTemplateCreate(ctx context.Context, t *testing.T, dao IpxeTemplateDAO, name, scope string) *IpxeTemplate { + tmpl, err := dao.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: name, + RequiredParams: []string{"kernel_params"}, + ReservedParams: []string{"base_url", "console"}, + RequiredArtifacts: []string{"kernel", "initrd"}, + Scope: scope, + }) + require.NoError(t, err) + require.NotNil(t, tmpl) + return tmpl +} + +func TestIpxeTemplateSQLDAO_Create(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + + tests := []struct { + desc string + input IpxeTemplateCreateInput + expectError bool + }{ + { + desc: "create public template", + input: IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "kernel-initrd", + RequiredParams: []string{"kernel_params"}, + ReservedParams: []string{"base_url", "console"}, + RequiredArtifacts: []string{"kernel", "initrd"}, + Scope: IpxeTemplateScopePublic, + }, + }, + { + desc: "create internal template", + input: IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "discovery-scout-x86_64", + RequiredParams: []string{"mac", "cli_cmd", "machine_id", "server_uri"}, + ReservedParams: []string{"base_url"}, + RequiredArtifacts: []string{}, + Scope: IpxeTemplateScopeInternal, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + tmpl, err := dao.Create(ctx, nil, tc.input) + assert.Equal(t, tc.expectError, err != nil) + if !tc.expectError { + require.NotNil(t, tmpl) + assert.Equal(t, tc.input.ID, tmpl.ID) + assert.Equal(t, tc.input.Name, tmpl.Name) + assert.Equal(t, tc.input.Scope, tmpl.Scope) + assert.Equal(t, tc.input.RequiredParams, tmpl.RequiredParams) + assert.Equal(t, tc.input.ReservedParams, tmpl.ReservedParams) + assert.Equal(t, tc.input.RequiredArtifacts, tmpl.RequiredArtifacts) + } + }) + } +} + +func TestIpxeTemplateSQLDAO_Get(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + created := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + + tests := []struct { + desc string + id uuid.UUID + expectError bool + }{ + {desc: "existing template", id: created.ID}, + {desc: "not found", id: uuid.New(), expectError: true}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := dao.Get(ctx, nil, tc.id) + assert.Equal(t, tc.expectError, err != nil) + if !tc.expectError { + require.NotNil(t, got) + assert.Equal(t, tc.id, got.ID) + assert.Equal(t, "kernel-initrd", got.Name) + } + }) + } +} + +func TestIpxeTemplateSQLDAO_GetAll(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + t1 := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + testIpxeTemplateCreate(ctx, t, dao, "ubuntu-autoinstall", IpxeTemplateScopePublic) + testIpxeTemplateCreate(ctx, t, dao, "discovery-scout-x86_64", IpxeTemplateScopeInternal) + + tests := []struct { + desc string + filter IpxeTemplateFilterInput + page paginator.PageInput + expectedCount int + expectedTotal *int + }{ + {desc: "no filter returns all", expectedCount: 3, expectedTotal: db.GetIntPtr(3)}, + {desc: "filter by id", filter: IpxeTemplateFilterInput{IDs: []uuid.UUID{t1.ID}}, expectedCount: 1}, + {desc: "filter by name", filter: IpxeTemplateFilterInput{Names: []string{"kernel-initrd"}}, expectedCount: 1}, + {desc: "limit applies", page: paginator.PageInput{Offset: db.GetIntPtr(0), Limit: db.GetIntPtr(2)}, expectedCount: 2, expectedTotal: db.GetIntPtr(3)}, + {desc: "offset applies", page: paginator.PageInput{Offset: db.GetIntPtr(1)}, expectedCount: 2, expectedTotal: db.GetIntPtr(3)}, + {desc: "unknown id returns empty", filter: IpxeTemplateFilterInput{IDs: []uuid.UUID{uuid.New()}}, expectedCount: 0}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, total, err := dao.GetAll(ctx, nil, tc.filter, tc.page) + require.NoError(t, err) + assert.Equal(t, tc.expectedCount, len(got)) + if tc.expectedTotal != nil { + assert.Equal(t, *tc.expectedTotal, total) + } + }) + } +} + +func TestIpxeTemplateSQLDAO_Update(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + created := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopeInternal) + + updated, err := dao.Update(ctx, nil, IpxeTemplateUpdateInput{ + IpxeTemplateID: created.ID, + Name: "kernel-initrd", + RequiredParams: []string{"kernel_params", "extra_option"}, + ReservedParams: []string{"base_url"}, + RequiredArtifacts: []string{"kernel"}, + Scope: IpxeTemplateScopePublic, + }) + require.NoError(t, err) + require.NotNil(t, updated) + + assert.Equal(t, created.ID, updated.ID) + assert.Equal(t, IpxeTemplateScopePublic, updated.Scope) + assert.Equal(t, []string{"kernel_params", "extra_option"}, updated.RequiredParams) + assert.Equal(t, []string{"base_url"}, updated.ReservedParams) + assert.Equal(t, []string{"kernel"}, updated.RequiredArtifacts) + assert.Equal(t, "kernel-initrd", updated.Name) +} + +func TestIpxeTemplateSQLDAO_Delete(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + t1 := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + testIpxeTemplateCreate(ctx, t, dao, "ubuntu-autoinstall", IpxeTemplateScopePublic) + + err := dao.Delete(ctx, nil, t1.ID) + require.NoError(t, err) + + _, err = dao.Get(ctx, nil, t1.ID) + assert.ErrorIs(t, err, db.ErrDoesNotExist) + + remaining, total, err := dao.GetAll(ctx, nil, IpxeTemplateFilterInput{}, paginator.PageInput{}) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Equal(t, "ubuntu-autoinstall", remaining[0].Name) + + err = dao.Delete(ctx, nil, uuid.New()) + assert.NoError(t, err) +} + +func TestIpxeTemplateSQLDAO_UniqueConstraint(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + + // Names are now globally unique. + _, err := dao.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "kernel-initrd", + Scope: IpxeTemplateScopePublic, + }) + assert.Error(t, err) +} + +func TestIpxeTemplateSQLDAO_DefaultArrayFields(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + + created, err := dao.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "ipxe-shell", + Scope: IpxeTemplateScopeInternal, + }) + require.NoError(t, err) + + retrieved, err := dao.Get(ctx, nil, created.ID) + require.NoError(t, err) + assert.NotNil(t, retrieved.RequiredParams) + assert.NotNil(t, retrieved.ReservedParams) + assert.NotNil(t, retrieved.RequiredArtifacts) +} diff --git a/db/pkg/db/model/ipxetemplatesiteassociation.go b/db/pkg/db/model/ipxetemplatesiteassociation.go new file mode 100644 index 000000000..b2c95b555 --- /dev/null +++ b/db/pkg/db/model/ipxetemplatesiteassociation.go @@ -0,0 +1,270 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "database/sql" + "time" + + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/paginator" + "github.com/google/uuid" + "github.com/uptrace/bun" + + stracer "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/tracer" +) + +const ( + // IpxeTemplateSiteAssociationOrderByDefault default field used for ordering when none specified + IpxeTemplateSiteAssociationOrderByDefault = "created" +) + +var ( + // IpxeTemplateSiteAssociationOrderByFields is a list of valid order by fields for the IpxeTemplateSiteAssociation model + IpxeTemplateSiteAssociationOrderByFields = []string{"created", "updated"} + + // IpxeTemplateSiteAssociationRelatedEntities is a list of valid relation by fields for the IpxeTemplateSiteAssociation model + IpxeTemplateSiteAssociationRelatedEntities = map[string]bool{ + IpxeTemplateRelationName: true, + SiteRelationName: true, + } +) + +// IpxeTemplateSiteAssociation records the availability of an IpxeTemplate at a Site. +// +// Unlike OSSA/SKGSA, REST is not the source of truth for templates (they flow from +// the site agent into REST), so this association does not track sync status, version, +// or controller state. The presence of a row indicates the template is available at +// the site; the row is removed when the site agent stops reporting the template. +type IpxeTemplateSiteAssociation struct { + bun.BaseModel `bun:"table:ipxe_template_site_association,alias:itsa"` + + ID uuid.UUID `bun:"type:uuid,pk"` + IpxeTemplateID uuid.UUID `bun:"ipxe_template_id,type:uuid,notnull"` + IpxeTemplate *IpxeTemplate `bun:"rel:belongs-to,join:ipxe_template_id=id"` + SiteID uuid.UUID `bun:"site_id,type:uuid,notnull"` + Site *Site `bun:"rel:belongs-to,join:site_id=id"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` +} + +// IpxeTemplateSiteAssociationCreateInput input parameters for the Create method +type IpxeTemplateSiteAssociationCreateInput struct { + IpxeTemplateID uuid.UUID + SiteID uuid.UUID +} + +// IpxeTemplateSiteAssociationFilterInput input parameters for the GetAll method +type IpxeTemplateSiteAssociationFilterInput struct { + IpxeTemplateIDs []uuid.UUID + SiteIDs []uuid.UUID +} + +var _ bun.BeforeAppendModelHook = (*IpxeTemplateSiteAssociation)(nil) + +// BeforeAppendModel is a hook called before the model is appended to the query +func (itsa *IpxeTemplateSiteAssociation) BeforeAppendModel(ctx context.Context, query bun.Query) error { + switch query.(type) { + case *bun.InsertQuery: + itsa.Created = db.GetCurTime() + itsa.Updated = db.GetCurTime() + case *bun.UpdateQuery: + itsa.Updated = db.GetCurTime() + } + return nil +} + +var _ bun.BeforeCreateTableHook = (*IpxeTemplateSiteAssociation)(nil) + +// BeforeCreateTable is a hook called before the table is created +func (itsa *IpxeTemplateSiteAssociation) BeforeCreateTable(ctx context.Context, query *bun.CreateTableQuery) error { + query.ForeignKey(`("site_id") REFERENCES "site" ("id")`). + ForeignKey(`("ipxe_template_id") REFERENCES "ipxe_template" ("id") ON DELETE CASCADE`) + return nil +} + +// IpxeTemplateSiteAssociationDAO is an interface for interacting with the IpxeTemplateSiteAssociation model +type IpxeTemplateSiteAssociationDAO interface { + // Create inserts a new association row + Create(ctx context.Context, tx *db.Tx, input IpxeTemplateSiteAssociationCreateInput) (*IpxeTemplateSiteAssociation, error) + // GetByID returns a row by primary key + GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) + // GetByIpxeTemplateIDAndSiteID returns the row matching the (template, site) pair + GetByIpxeTemplateIDAndSiteID(ctx context.Context, tx *db.Tx, ipxeTemplateID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) + // GetAll returns all rows matching the filter and page inputs + GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateSiteAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]IpxeTemplateSiteAssociation, int, error) + // Delete removes a row by ID + Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error +} + +// IpxeTemplateSiteAssociationSQLDAO is an implementation of the IpxeTemplateSiteAssociationDAO interface +type IpxeTemplateSiteAssociationSQLDAO struct { + dbSession *db.Session + IpxeTemplateSiteAssociationDAO + tracerSpan *stracer.TracerSpan +} + +// Create creates a new IpxeTemplateSiteAssociation +func (itsasd IpxeTemplateSiteAssociationSQLDAO) Create( + ctx context.Context, tx *db.Tx, + input IpxeTemplateSiteAssociationCreateInput, +) (*IpxeTemplateSiteAssociation, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.Create") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "ipxe_template_id", input.IpxeTemplateID.String()) + itsasd.tracerSpan.SetAttribute(span, "site_id", input.SiteID.String()) + } + + itsa := &IpxeTemplateSiteAssociation{ + ID: uuid.New(), + IpxeTemplateID: input.IpxeTemplateID, + SiteID: input.SiteID, + } + + _, err := db.GetIDB(tx, itsasd.dbSession).NewInsert().Model(itsa).Exec(ctx) + if err != nil { + return nil, err + } + + return itsasd.GetByID(ctx, tx, itsa.ID, nil) +} + +// GetByID returns an IpxeTemplateSiteAssociation by ID +// Returns db.ErrDoesNotExist if the record is not found +func (itsasd IpxeTemplateSiteAssociationSQLDAO) GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.GetByID") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "id", id.String()) + } + + itsa := &IpxeTemplateSiteAssociation{} + + query := db.GetIDB(tx, itsasd.dbSession).NewSelect().Model(itsa).Where("itsa.id = ?", id) + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + err := query.Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return itsa, nil +} + +// GetByIpxeTemplateIDAndSiteID returns an IpxeTemplateSiteAssociation by (template, site). +// Returns db.ErrDoesNotExist if the record is not found. +func (itsasd IpxeTemplateSiteAssociationSQLDAO) GetByIpxeTemplateIDAndSiteID(ctx context.Context, tx *db.Tx, ipxeTemplateID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.GetByIpxeTemplateIDAndSiteID") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "ipxe_template_id", ipxeTemplateID.String()) + itsasd.tracerSpan.SetAttribute(span, "site_id", siteID.String()) + } + + itsa := &IpxeTemplateSiteAssociation{} + + query := db.GetIDB(tx, itsasd.dbSession).NewSelect().Model(itsa). + Where("itsa.ipxe_template_id = ?", ipxeTemplateID). + Where("itsa.site_id = ?", siteID) + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + err := query.Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return itsa, nil +} + +// GetAll returns all IpxeTemplateSiteAssociation rows with optional filters +func (itsasd IpxeTemplateSiteAssociationSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateSiteAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]IpxeTemplateSiteAssociation, int, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.GetAll") + if span != nil { + defer span.End() + } + + itsas := []IpxeTemplateSiteAssociation{} + + query := db.GetIDB(tx, itsasd.dbSession).NewSelect().Model(&itsas) + if len(filter.IpxeTemplateIDs) > 0 { + query = query.Where("itsa.ipxe_template_id IN (?)", bun.In(filter.IpxeTemplateIDs)) + if span != nil { + itsasd.tracerSpan.SetAttribute(span, "ipxe_template_ids", filter.IpxeTemplateIDs) + } + } + if len(filter.SiteIDs) > 0 { + query = query.Where("itsa.site_id IN (?)", bun.In(filter.SiteIDs)) + if span != nil { + itsasd.tracerSpan.SetAttribute(span, "site_ids", filter.SiteIDs) + } + } + + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + if page.OrderBy == nil { + page.OrderBy = paginator.NewDefaultOrderBy(IpxeTemplateSiteAssociationOrderByDefault) + } + + pager, err := paginator.NewPaginator(ctx, query, page.Offset, page.Limit, page.OrderBy, IpxeTemplateSiteAssociationOrderByFields) + if err != nil { + return nil, 0, err + } + + err = pager.Query.Limit(pager.Limit).Offset(pager.Offset).Scan(ctx) + if err != nil { + return nil, 0, err + } + + return itsas, pager.Total, nil +} + +// Delete removes an IpxeTemplateSiteAssociation by ID +func (itsasd IpxeTemplateSiteAssociationSQLDAO) Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.Delete") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "id", id.String()) + } + + itsa := &IpxeTemplateSiteAssociation{ID: id} + + _, err := db.GetIDB(tx, itsasd.dbSession).NewDelete().Model(itsa).Where("itsa.id = ?", id).Exec(ctx) + return err +} + +// NewIpxeTemplateSiteAssociationDAO returns a new IpxeTemplateSiteAssociationDAO +func NewIpxeTemplateSiteAssociationDAO(dbSession *db.Session) IpxeTemplateSiteAssociationDAO { + return &IpxeTemplateSiteAssociationSQLDAO{ + dbSession: dbSession, + tracerSpan: stracer.NewTracerSpan(), + } +} diff --git a/db/pkg/db/model/ipxetemplatesiteassociation_test.go b/db/pkg/db/model/ipxetemplatesiteassociation_test.go new file mode 100644 index 000000000..9d40cc1e0 --- /dev/null +++ b/db/pkg/db/model/ipxetemplatesiteassociation_test.go @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "testing" + + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db" + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/paginator" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testIpxeTemplateSiteAssociationSetupSchema(t *testing.T, dbSession *db.Session) { + ctx := context.Background() + require.Nil(t, dbSession.DB.ResetModel(ctx, (*User)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*InfrastructureProvider)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*Site)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*IpxeTemplate)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*IpxeTemplateSiteAssociation)(nil))) + + _, err := dbSession.DB.Exec("ALTER TABLE ipxe_template DROP CONSTRAINT IF EXISTS ipxe_template_name_key") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template ADD CONSTRAINT ipxe_template_name_key UNIQUE (name)") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template_site_association DROP CONSTRAINT IF EXISTS ipxe_template_site_association_template_id_site_id_key") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template_site_association ADD CONSTRAINT ipxe_template_site_association_template_id_site_id_key UNIQUE (ipxe_template_id, site_id)") + require.Nil(t, err) +} + +func TestIpxeTemplateSiteAssociationSQLDAO_CreateGetDelete(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSiteAssociationSetupSchema(t, dbSession) + + user := TestBuildUser(t, dbSession, "test-user", "test-org", []string{"admin"}) + ip := TestBuildInfrastructureProvider(t, dbSession, "test-provider", "test-org", user) + site := TestBuildSite(t, dbSession, ip, "test-site", user) + + tmplDAO := NewIpxeTemplateDAO(dbSession) + tmpl, err := tmplDAO.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "kernel-initrd", Scope: IpxeTemplateScopePublic, + }) + require.NoError(t, err) + + dao := NewIpxeTemplateSiteAssociationDAO(dbSession) + + itsa, err := dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{ + IpxeTemplateID: tmpl.ID, + SiteID: site.ID, + }) + require.NoError(t, err) + assert.Equal(t, tmpl.ID, itsa.IpxeTemplateID) + assert.Equal(t, site.ID, itsa.SiteID) + + got, err := dao.GetByID(ctx, nil, itsa.ID, nil) + require.NoError(t, err) + assert.Equal(t, itsa.ID, got.ID) + + got, err = dao.GetByIpxeTemplateIDAndSiteID(ctx, nil, tmpl.ID, site.ID, nil) + require.NoError(t, err) + assert.Equal(t, itsa.ID, got.ID) + + _, err = dao.GetByIpxeTemplateIDAndSiteID(ctx, nil, uuid.New(), site.ID, nil) + assert.ErrorIs(t, err, db.ErrDoesNotExist) + + require.NoError(t, dao.Delete(ctx, nil, itsa.ID)) + _, err = dao.GetByID(ctx, nil, itsa.ID, nil) + assert.ErrorIs(t, err, db.ErrDoesNotExist) +} + +func TestIpxeTemplateSiteAssociationSQLDAO_GetAllAndUniqueness(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSiteAssociationSetupSchema(t, dbSession) + + user := TestBuildUser(t, dbSession, "test-user", "test-org", []string{"admin"}) + ip := TestBuildInfrastructureProvider(t, dbSession, "test-provider", "test-org", user) + site1 := TestBuildSite(t, dbSession, ip, "site-1", user) + site2 := TestBuildSite(t, dbSession, ip, "site-2", user) + + tmplDAO := NewIpxeTemplateDAO(dbSession) + tmpl1, err := tmplDAO.Create(ctx, nil, IpxeTemplateCreateInput{ID: uuid.New(), Name: "tmpl-a", Scope: IpxeTemplateScopePublic}) + require.NoError(t, err) + tmpl2, err := tmplDAO.Create(ctx, nil, IpxeTemplateCreateInput{ID: uuid.New(), Name: "tmpl-b", Scope: IpxeTemplateScopePublic}) + require.NoError(t, err) + + dao := NewIpxeTemplateSiteAssociationDAO(dbSession) + + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl1.ID, SiteID: site1.ID}) + require.NoError(t, err) + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl1.ID, SiteID: site2.ID}) + require.NoError(t, err) + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl2.ID, SiteID: site1.ID}) + require.NoError(t, err) + + // Duplicate (template, site) pair must fail + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl1.ID, SiteID: site1.ID}) + assert.Error(t, err) + + rows, total, err := dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 3, total) + assert.Len(t, rows, 3) + + rows, total, err = dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{SiteIDs: []uuid.UUID{site1.ID}}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 2, total) + assert.Len(t, rows, 2) + + rows, total, err = dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{IpxeTemplateIDs: []uuid.UUID{tmpl1.ID}}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 2, total) + assert.Len(t, rows, 2) + + rows, total, err = dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{ + IpxeTemplateIDs: []uuid.UUID{tmpl1.ID}, + SiteIDs: []uuid.UUID{site2.ID}, + }, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Len(t, rows, 1) +} diff --git a/db/pkg/db/model/operatingsystem.go b/db/pkg/db/model/operatingsystem.go index f9147e802..62e0d9d43 100644 --- a/db/pkg/db/model/operatingsystem.go +++ b/db/pkg/db/model/operatingsystem.go @@ -29,6 +29,8 @@ import ( "github.com/uptrace/bun" stracer "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/tracer" + + ws "github.com/NVIDIA/ncx-infra-controller-rest/workflow-schema/schema/site-agent/workflows/v1" ) const ( @@ -49,8 +51,17 @@ const ( // OperatingSystemRelationName is the relation name for the OperatingSystem model OperatingSystemRelationName = "OperatingSystem" - // OperatingSystemTypeIPXE is the ipxe based OperatingSystem type + + // OperatingSystemScopeLocal means single site, bidirectional sync (provider-owned OS from carbide-core). + OperatingSystemScopeLocal = "Local" + // OperatingSystemScopeLimited means carbide-rest is the source of truth for a fixed list of sites. + OperatingSystemScopeLimited = "Limited" + // OperatingSystemScopeGlobal means carbide-rest is the source of truth for all owner sites. + OperatingSystemScopeGlobal = "Global" + // OperatingSystemTypeIPXE is the raw iPXE script based OperatingSystem type OperatingSystemTypeIPXE = "iPXE" + // OperatingSystemTypeTemplatedIPXE is the iPXE template based OperatingSystem type + OperatingSystemTypeTemplatedIPXE = "Templated iPXE" // OperatingSystemTypeImage is the image based OperatingSystem type OperatingSystemTypeImage = "Image" @@ -61,6 +72,15 @@ const ( OperatingSystemAuthTypeBasic = "Basic" // OperatingSystemAuthTypeBearer is the bearer image auth type OperatingSystemAuthTypeBearer = "Bearer" + + // OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded is the cache as needed strategy + OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded = "CacheAsNeeded" + // OperatingSystemIpxeArtifactCacheStrategyLocalOnly is the local only strategy + OperatingSystemIpxeArtifactCacheStrategyLocalOnly = "LocalOnly" + // OperatingSystemIpxeArtifactCacheStrategyCachedOnly is the cached only strategy + OperatingSystemIpxeArtifactCacheStrategyCachedOnly = "CachedOnly" + // OperatingSystemIpxeArtifactCacheStrategyRemoteOnly is the remote only strategy + OperatingSystemIpxeArtifactCacheStrategyRemoteOnly = "RemoteOnly" ) var ( @@ -83,123 +103,253 @@ var ( } //OperatingSystemsTypeMap is a list of valid type for the OperatingSystem model OperatingSystemsTypeMap = map[string]bool{ - OperatingSystemTypeIPXE: true, - OperatingSystemTypeImage: true, + OperatingSystemTypeIPXE: true, + OperatingSystemTypeTemplatedIPXE: true, + OperatingSystemTypeImage: true, + } + + OperatingSystemTypeFromProtoMap = map[ws.OperatingSystemType]string{ + ws.OperatingSystemType_OS_TYPE_IPXE: OperatingSystemTypeIPXE, + ws.OperatingSystemType_OS_TYPE_TEMPLATED_IPXE: OperatingSystemTypeTemplatedIPXE, + } + + OperatingSystemStatusFromProtoMap = map[ws.TenantState]string{ + ws.TenantState_PROVISIONING: OperatingSystemStatusProvisioning, + ws.TenantState_READY: OperatingSystemStatusReady, + ws.TenantState_CONFIGURING: OperatingSystemStatusSyncing, + ws.TenantState_TERMINATING: OperatingSystemStatusDeleting, + ws.TenantState_FAILED: OperatingSystemStatusError, + } + + OperatingSystemIpxeArtifactCacheStrategyFromProtoMap = map[ws.IpxeTemplateArtifactCacheStrategy]string{ + ws.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED: OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded, + ws.IpxeTemplateArtifactCacheStrategy_LOCAL_ONLY: OperatingSystemIpxeArtifactCacheStrategyLocalOnly, + ws.IpxeTemplateArtifactCacheStrategy_CACHED_ONLY: OperatingSystemIpxeArtifactCacheStrategyCachedOnly, + ws.IpxeTemplateArtifactCacheStrategy_REMOTE_ONLY: OperatingSystemIpxeArtifactCacheStrategyRemoteOnly, + } + + OperatingSystemIpxeArtifactCacheStrategyToProtoMap = map[string]ws.IpxeTemplateArtifactCacheStrategy{ + OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded: ws.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED, + OperatingSystemIpxeArtifactCacheStrategyLocalOnly: ws.IpxeTemplateArtifactCacheStrategy_LOCAL_ONLY, + OperatingSystemIpxeArtifactCacheStrategyCachedOnly: ws.IpxeTemplateArtifactCacheStrategy_CACHED_ONLY, + OperatingSystemIpxeArtifactCacheStrategyRemoteOnly: ws.IpxeTemplateArtifactCacheStrategy_REMOTE_ONLY, } ) +// IsIPXEType returns true if the given OS type is any iPXE variant (raw script or templated). +func IsIPXEType(osType string) bool { + return osType == OperatingSystemTypeIPXE || osType == OperatingSystemTypeTemplatedIPXE +} + +// OperatingSystemIpxeParameter holds a single iPXE parameter name/value pair (stored as JSONB). +// These are only populated for iPXE-based OS definitions synced from carbide-core. +type OperatingSystemIpxeParameter struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// FromProto converts a proto IpxeTemplateParameter to an OperatingSystemIpxeParameter +func (osip *OperatingSystemIpxeParameter) FromProto(protoParam *ws.IpxeTemplateParameter) { + osip.Name = protoParam.Name + osip.Value = protoParam.Value +} + +// ToProto converts an OperatingSystemIpxeParameter to a proto IpxeTemplateParameter +func (osip *OperatingSystemIpxeParameter) ToProto() *ws.IpxeTemplateParameter { + return &ws.IpxeTemplateParameter{ + Name: osip.Name, + Value: osip.Value, + } +} + +// OperatingSystemIpxeArtifact holds a single iPXE artifact descriptor (stored as JSONB). +// These are only populated for iPXE-based OS definitions synced from carbide-core. +// +// Note: the proto IpxeTemplateArtifact has a `cached_url` field that is +// intentionally NOT represented here. cached_url is a per-site value populated +// by carbide-core after a successful download; there is no meaningful global +// value for it on the rest side. The push activity must therefore never +// emit cached_url to core (so existing per-site values are preserved), and +// the inbound (pull) activity must never store cached_url on the global +// OperatingSystem row. +type OperatingSystemIpxeArtifact struct { + Name string `json:"name"` + URL string `json:"url"` + SHA *string `json:"sha"` + AuthType *string `json:"authType"` + AuthToken *string `json:"authToken"` + CacheStrategy string `json:"cacheStrategy"` +} + +// FromProto converts a proto IpxeTemplateArtifact to an OperatingSystemIpxeArtifact. +// The proto's cached_url field is intentionally ignored — see the type doc. +func (osia *OperatingSystemIpxeArtifact) FromProto(protoArtifact *ws.IpxeTemplateArtifact) { + osia.Name = protoArtifact.Name + osia.URL = protoArtifact.Url + osia.SHA = protoArtifact.Sha + osia.AuthType = protoArtifact.AuthType + osia.AuthToken = protoArtifact.AuthToken + + cacheStrategy := OperatingSystemIpxeArtifactCacheStrategyFromProtoMap[protoArtifact.CacheStrategy] + if cacheStrategy == "" { + cacheStrategy = OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded + } + osia.CacheStrategy = cacheStrategy +} + +// ToProto converts an OperatingSystemIpxeArtifact to a proto IpxeTemplateArtifact +func (osia *OperatingSystemIpxeArtifact) ToProto() *ws.IpxeTemplateArtifact { + return &ws.IpxeTemplateArtifact{ + Name: osia.Name, + Url: osia.URL, + Sha: osia.SHA, + AuthType: osia.AuthType, + AuthToken: osia.AuthToken, + CacheStrategy: OperatingSystemIpxeArtifactCacheStrategyToProtoMap[osia.CacheStrategy], + CachedUrl: nil, // rest side never update core local value for CachedUrl: it is managed on the core side. + } +} + // OperatingSystem describes the attributes of the operating system // that can be used on instances type OperatingSystem struct { bun.BaseModel `bun:"table:operating_system,alias:os"` - ID uuid.UUID `bun:"type:uuid,pk"` - Name string `bun:"name,notnull"` - Description *string `bun:"description"` - Org string `bun:"org,notnull"` - InfrastructureProviderID *uuid.UUID `bun:"infrastructure_provider_id,type:uuid"` - InfrastructureProvider *InfrastructureProvider `bun:"rel:belongs-to,join:infrastructure_provider_id=id"` - TenantID *uuid.UUID `bun:"tenant_id,type:uuid"` - Tenant *Tenant `bun:"rel:belongs-to,join:tenant_id=id"` - ControllerOperatingSystemID *uuid.UUID `bun:"controller_operating_system_id,type:uuid"` - Version *string `bun:"version"` - Type string `bun:"type,notnull"` - ImageURL *string `bun:"image_url"` - ImageSHA *string `bun:"image_sha"` - ImageAuthType *string `bun:"image_auth_type"` - ImageAuthToken *string `bun:"image_auth_token"` - ImageDisk *string `bun:"image_disk"` - RootFsID *string `bun:"root_fs_id"` - RootFsLabel *string `bun:"root_fs_label"` - IpxeScript *string `bun:"ipxe_script"` - UserData *string `bun:"user_data"` - IsCloudInit bool `bun:"is_cloud_init,notnull"` - AllowOverride bool `bun:"allow_override,notnull"` - EnableBlockStorage bool `bun:"enable_block_storage,notnull"` - PhoneHomeEnabled bool `bun:"phone_home_enabled,notnull"` - IsActive bool `bun:"is_active,notnull"` - DeactivationNote *string `bun:"deactivation_note"` // Note for deactivation, if any - Status string `bun:"status,notnull"` - Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` - Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` - Deleted *time.Time `bun:"deleted,soft_delete"` - CreatedBy uuid.UUID `bun:"type:uuid,notnull"` + ID uuid.UUID `bun:"type:uuid,pk"` + Name string `bun:"name,notnull"` + Description *string `bun:"description"` + Org string `bun:"org,notnull"` + InfrastructureProviderID *uuid.UUID `bun:"infrastructure_provider_id,type:uuid"` + InfrastructureProvider *InfrastructureProvider `bun:"rel:belongs-to,join:infrastructure_provider_id=id"` + TenantID *uuid.UUID `bun:"tenant_id,type:uuid"` + Tenant *Tenant `bun:"rel:belongs-to,join:tenant_id=id"` + Version *string `bun:"version"` + Type string `bun:"type,notnull"` + ImageURL *string `bun:"image_url"` + ImageSHA *string `bun:"image_sha"` + ImageAuthType *string `bun:"image_auth_type"` + ImageAuthToken *string `bun:"image_auth_token"` + ImageDisk *string `bun:"image_disk"` + RootFsID *string `bun:"root_fs_id"` + RootFsLabel *string `bun:"root_fs_label"` + IpxeScript *string `bun:"ipxe_script"` + // iPXE fields populated for OS definitions synced from carbide-core (type = iPXE) + IpxeTemplateId *string `bun:"ipxe_template_id"` + IpxeTemplateParameters []OperatingSystemIpxeParameter `bun:"ipxe_template_parameters,type:jsonb"` + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `bun:"ipxe_template_artifacts,type:jsonb"` + IpxeTemplateDefinitionHash *string `bun:"ipxe_template_definition_hash"` + // IpxeOsScope controls synchronization direction between carbide-rest and carbide-core. + // "Local" means bidirectional, provider-owned from carbide-core. + // "Global" and "Limited" mean carbide-rest is the source of truth. + // Set for all iPXE types (raw and templated); nil for Image-type OS. + // Tenant raw iPXE is auto-set to "Global"; provider iPXE from core is "Local". + // Legacy records with nil scope are treated as "Local" and backfilled by migration. + IpxeOsScope *string `bun:"ipxe_os_scope"` + UserData *string `bun:"user_data"` + IsCloudInit bool `bun:"is_cloud_init,notnull"` + AllowOverride bool `bun:"allow_override,notnull"` + EnableBlockStorage bool `bun:"enable_block_storage,notnull"` + PhoneHomeEnabled bool `bun:"phone_home_enabled,notnull"` + IsActive bool `bun:"is_active,notnull"` + DeactivationNote *string `bun:"deactivation_note"` // Note for deactivation, if any + Status string `bun:"status,notnull"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` + Deleted *time.Time `bun:"deleted,soft_delete"` + CreatedBy uuid.UUID `bun:"type:uuid,notnull"` } // OperatingSystemCreateInput input parameters for Create method type OperatingSystemCreateInput struct { - Name string - Description *string - Org string - InfrastructureProviderID *uuid.UUID - TenantID *uuid.UUID - ControllerOperatingSystemID *uuid.UUID - Version *string - OsType string - ImageURL *string - ImageSHA *string - ImageAuthType *string - ImageAuthToken *string - ImageDisk *string - RootFsId *string - RootFsLabel *string - IpxeScript *string - UserData *string - IsCloudInit bool - AllowOverride bool - EnableBlockStorage bool - PhoneHomeEnabled bool - Status string - CreatedBy uuid.UUID + // ID optionally pre-specifies the primary key. When set (e.g. during inventory sync from + // carbide-core), the same UUID is used on both sides. When zero, a new UUID is generated. + ID uuid.UUID + Name string + Description *string + Org string + InfrastructureProviderID *uuid.UUID + TenantID *uuid.UUID + Version *string + OsType string + ImageURL *string + ImageSHA *string + ImageAuthType *string + ImageAuthToken *string + ImageDisk *string + RootFsId *string + RootFsLabel *string + IpxeScript *string + UserData *string + IsCloudInit bool + AllowOverride bool + EnableBlockStorage bool + PhoneHomeEnabled bool + // iPXE definition fields (for carbide-core synced iPXE OS definitions) + IpxeTemplateId *string + IpxeTemplateParameters []OperatingSystemIpxeParameter + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact + IpxeOSHash *string + IpxeOsScope *string + Status string + CreatedBy uuid.UUID } // OperatingSystemUpdateInput input parameters for Update method type OperatingSystemUpdateInput struct { - OperatingSystemId uuid.UUID - Name *string - Description *string - Org *string - InfrastructureProviderID *uuid.UUID - TenantID *uuid.UUID - ControllerOperatingSystemID *uuid.UUID - Version *string - OsType *string - ImageURL *string - ImageSHA *string - ImageAuthType *string - ImageAuthToken *string - ImageDisk *string - RootFsId *string - RootFsLabel *string - IpxeScript *string - UserData *string - IsCloudInit *bool - AllowOverride *bool - EnableBlockStorage *bool - PhoneHomeEnabled *bool - IsActive *bool - DeactivationNote *string - Status *string + OperatingSystemId uuid.UUID + Name *string + Description *string + Org *string + InfrastructureProviderID *uuid.UUID + TenantID *uuid.UUID + Version *string + OsType *string + ImageURL *string + ImageSHA *string + ImageAuthType *string + ImageAuthToken *string + ImageDisk *string + RootFsId *string + RootFsLabel *string + IpxeScript *string + UserData *string + IsCloudInit *bool + AllowOverride *bool + EnableBlockStorage *bool + PhoneHomeEnabled *bool + IsActive *bool + DeactivationNote *string + // iPXE definition fields (for carbide-core synced iPXE OS definitions) + IpxeTemplateId *string + IpxeTemplateParameters *[]OperatingSystemIpxeParameter + IpxeTemplateArtifacts *[]OperatingSystemIpxeArtifact + IpxeOSHash *string + Scope *string + Status *string } // OperatingSystemClearInput input parameters for Clear method type OperatingSystemClearInput struct { - OperatingSystemId uuid.UUID - Description bool - InfrastructureProviderID bool - TenantID bool - ControllerOperatingSystemID bool - Version bool - ImageURL bool - ImageSHA bool - ImageAuthType bool - ImageAuthToken bool - ImageDisk bool - RootFsId bool - RootFsLabel bool - IpxeScript bool - UserData bool - DeactivationNote bool + OperatingSystemId uuid.UUID + Description bool + InfrastructureProviderID bool + TenantID bool + Version bool + ImageURL bool + ImageSHA bool + ImageAuthType bool + ImageAuthToken bool + ImageDisk bool + RootFsId bool + RootFsLabel bool + IpxeScript bool + UserData bool + DeactivationNote bool + IpxeTemplateId bool + IpxeTemplateParameters bool + IpxeTemplateArtifacts bool + IpxeOSHash bool + Scope bool } type OperatingSystemFilterInput struct { @@ -213,6 +363,20 @@ type OperatingSystemFilterInput struct { SearchQuery *string OperatingSystemIds []uuid.UUID IsActive *bool + // Scopes filters by the scope field (e.g. "Global", "Limited", "Local"). + Scopes []string + // IncludeDeleted includes soft-deleted records in the result. + // Used by the inventory sync to detect and propagate deletions from carbide-core. + IncludeDeleted bool + + // ProviderOSVisibleAtSiteIDs restricts provider-owned OS visibility when + // InfrastructureProviderID is set together with TenantIDs (tenant admin view). + // Only provider-owned OSes with at least one site association at one of these + // sites are included. If nil, no cross-ownership provider entries are shown + // alongside tenant entries (default). If set to an empty slice, no provider + // entries match. This field is ignored when InfrastructureProviderID is used + // without TenantIDs (provider-only view). + ProviderOSVisibleAtSiteIDs *[]uuid.UUID } var _ bun.BeforeAppendModelHook = (*OperatingSystem)(nil) @@ -274,34 +438,42 @@ func (ossd OperatingSystemSQLDAO) Create(ctx context.Context, tx *db.Tx, input O ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "name", input.Name) } + id := input.ID + if id == uuid.Nil { + id = uuid.New() + } os := &OperatingSystem{ - ID: uuid.New(), - Name: input.Name, - Description: input.Description, - Org: input.Org, - InfrastructureProviderID: input.InfrastructureProviderID, - TenantID: input.TenantID, - ControllerOperatingSystemID: input.ControllerOperatingSystemID, - Version: input.Version, - Type: input.OsType, - ImageURL: input.ImageURL, - ImageSHA: input.ImageSHA, - ImageAuthType: input.ImageAuthType, - ImageAuthToken: input.ImageAuthToken, - ImageDisk: input.ImageDisk, - RootFsID: input.RootFsId, - RootFsLabel: input.RootFsLabel, - IpxeScript: input.IpxeScript, - UserData: input.UserData, - IsCloudInit: input.IsCloudInit, - AllowOverride: input.AllowOverride, - EnableBlockStorage: input.EnableBlockStorage, - PhoneHomeEnabled: input.PhoneHomeEnabled, + ID: id, + Name: input.Name, + Description: input.Description, + Org: input.Org, + InfrastructureProviderID: input.InfrastructureProviderID, + TenantID: input.TenantID, + Version: input.Version, + Type: input.OsType, + ImageURL: input.ImageURL, + ImageSHA: input.ImageSHA, + ImageAuthType: input.ImageAuthType, + ImageAuthToken: input.ImageAuthToken, + ImageDisk: input.ImageDisk, + RootFsID: input.RootFsId, + RootFsLabel: input.RootFsLabel, + IpxeScript: input.IpxeScript, + UserData: input.UserData, + IsCloudInit: input.IsCloudInit, + AllowOverride: input.AllowOverride, + EnableBlockStorage: input.EnableBlockStorage, + PhoneHomeEnabled: input.PhoneHomeEnabled, // WARNING: there is a bug in 'bun' and we cannot use non-nullable AND default=true at this time: - IsActive: true, // input.IsActive, - DeactivationNote: nil, //input.DeactivationNote, - Status: input.Status, - CreatedBy: input.CreatedBy, + IsActive: true, // input.IsActive, + DeactivationNote: nil, //input.DeactivationNote, + Status: input.Status, + CreatedBy: input.CreatedBy, + IpxeTemplateId: input.IpxeTemplateId, + IpxeTemplateParameters: input.IpxeTemplateParameters, + IpxeTemplateArtifacts: input.IpxeTemplateArtifacts, + IpxeTemplateDefinitionHash: input.IpxeOSHash, + IpxeOsScope: input.IpxeOsScope, } _, err := db.GetIDB(tx, ossd.dbSession).NewInsert().Model(os).Exec(ctx) @@ -375,13 +547,39 @@ func (ossd OperatingSystemSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter query = query.Where("os.org IN (?)", bun.In(filter.Orgs)) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "filter.org", filter.Orgs) } - if filter.InfrastructureProviderID != nil { - query = query.Where("os.infrastructure_provider_id = ?", *filter.InfrastructureProviderID) - ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "infrastructure_provider_id", filter.InfrastructureProviderID.String()) - } - if filter.TenantIDs != nil { + hasTenants := len(filter.TenantIDs) > 0 + hasProvider := filter.InfrastructureProviderID != nil + hasSiteScope := filter.ProviderOSVisibleAtSiteIDs != nil + + switch { + case hasTenants && hasProvider && hasSiteScope: + // Tenant admin view: own tenant entries + provider entries at accessible sites. + query = query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + q = q.Where("os.tenant_id IN (?)", bun.In(filter.TenantIDs)) + if len(*filter.ProviderOSVisibleAtSiteIDs) > 0 { + q = q.WhereOr( + "(os.infrastructure_provider_id = ? AND EXISTS (SELECT 1 FROM operating_system_site_association WHERE operating_system_id = os.id AND deleted IS NULL AND site_id IN (?)))", + *filter.InfrastructureProviderID, bun.In(*filter.ProviderOSVisibleAtSiteIDs), + ) + } + return q + }) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_with_provider_at_sites", filter.TenantIDs) + case hasTenants && hasProvider: + // Dual-role view: own tenant entries + own provider entries, no site restriction. + query = query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + return q. + Where("os.tenant_id IN (?)", bun.In(filter.TenantIDs)). + WhereOr("os.infrastructure_provider_id = ?", *filter.InfrastructureProviderID) + }) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_or_provider", filter.TenantIDs) + case hasTenants: query = query.Where("os.tenant_id IN (?)", bun.In(filter.TenantIDs)) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_id", filter.TenantIDs) + case hasProvider: + // Provider-only view: only provider-owned entries. + query = query.Where("os.infrastructure_provider_id = ?", *filter.InfrastructureProviderID) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "infrastructure_provider_id", filter.InfrastructureProviderID.String()) } if filter.OsTypes != nil { query = query.Where("os.type IN (?)", bun.In(filter.OsTypes)) @@ -417,6 +615,13 @@ func (ossd OperatingSystemSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter query = query.Where("os.is_active = ?", *filter.IsActive) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "is_active", *filter.IsActive) } + if filter.Scopes != nil { + query = query.Where("COALESCE(os.ipxe_os_scope, 'Local') IN (?)", bun.In(filter.Scopes)) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "scopes", filter.Scopes) + } + if filter.IncludeDeleted { + query = query.WhereAllWithDeleted() + } for _, relation := range includeRelations { query = query.Relation(relation) @@ -483,11 +688,6 @@ func (ossd OperatingSystemSQLDAO) Update(ctx context.Context, tx *db.Tx, input O updatedFields = append(updatedFields, "tenant_id") ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_id", input.TenantID.String()) } - if input.ControllerOperatingSystemID != nil { - it.ControllerOperatingSystemID = input.ControllerOperatingSystemID - updatedFields = append(updatedFields, "controller_operating_system_id") - ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "controller_operating_system_id", input.ControllerOperatingSystemID.String()) - } if input.Version != nil { it.Version = input.Version updatedFields = append(updatedFields, "version") @@ -578,6 +778,26 @@ func (ossd OperatingSystemSQLDAO) Update(ctx context.Context, tx *db.Tx, input O updatedFields = append(updatedFields, "status") ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "status", *input.Status) } + if input.IpxeTemplateId != nil { + it.IpxeTemplateId = input.IpxeTemplateId + updatedFields = append(updatedFields, "ipxe_template_id") + } + if input.IpxeTemplateParameters != nil { + it.IpxeTemplateParameters = *input.IpxeTemplateParameters + updatedFields = append(updatedFields, "ipxe_template_parameters") + } + if input.IpxeTemplateArtifacts != nil { + it.IpxeTemplateArtifacts = *input.IpxeTemplateArtifacts + updatedFields = append(updatedFields, "ipxe_template_artifacts") + } + if input.IpxeOSHash != nil { + it.IpxeTemplateDefinitionHash = input.IpxeOSHash + updatedFields = append(updatedFields, "ipxe_template_definition_hash") + } + if input.Scope != nil { + it.IpxeOsScope = input.Scope + updatedFields = append(updatedFields, "ipxe_os_scope") + } if len(updatedFields) > 0 { updatedFields = append(updatedFields, "updated") @@ -626,10 +846,6 @@ func (ossd OperatingSystemSQLDAO) Clear(ctx context.Context, tx *db.Tx, input Op it.TenantID = nil updatedFields = append(updatedFields, "tenant_id") } - if input.ControllerOperatingSystemID { - it.ControllerOperatingSystemID = nil - updatedFields = append(updatedFields, "controller_operating_system_id") - } if input.Version { it.Version = nil updatedFields = append(updatedFields, "version") @@ -674,6 +890,26 @@ func (ossd OperatingSystemSQLDAO) Clear(ctx context.Context, tx *db.Tx, input Op it.DeactivationNote = nil updatedFields = append(updatedFields, "deactivation_note") } + if input.IpxeTemplateId { + it.IpxeTemplateId = nil + updatedFields = append(updatedFields, "ipxe_template_id") + } + if input.IpxeTemplateParameters { + it.IpxeTemplateParameters = nil + updatedFields = append(updatedFields, "ipxe_template_parameters") + } + if input.IpxeTemplateArtifacts { + it.IpxeTemplateArtifacts = nil + updatedFields = append(updatedFields, "ipxe_template_artifacts") + } + if input.IpxeOSHash { + it.IpxeTemplateDefinitionHash = nil + updatedFields = append(updatedFields, "ipxe_template_definition_hash") + } + if input.Scope { + it.IpxeOsScope = nil + updatedFields = append(updatedFields, "ipxe_os_scope") + } if len(updatedFields) > 0 { updatedFields = append(updatedFields, "updated") diff --git a/db/pkg/db/model/operatingsystem_test.go b/db/pkg/db/model/operatingsystem_test.go index 8b57620c3..d406002c1 100644 --- a/db/pkg/db/model/operatingsystem_test.go +++ b/db/pkg/db/model/operatingsystem_test.go @@ -169,29 +169,28 @@ func TestOperatingSystemSQLDAO_Create(t *testing.T) { for _, i := range tc.its { os, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: i.Name, - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: i.InfrastructureProviderID, - TenantID: i.TenantID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "ipxe", - ImageURL: db.GetStrPtr("imageURL"), - ImageSHA: db.GetStrPtr("imageSHA"), - ImageAuthType: db.GetStrPtr("imageAuthType"), - ImageAuthToken: db.GetStrPtr("imageAuthToken"), - ImageDisk: db.GetStrPtr("imageDisk"), - RootFsId: db.GetStrPtr("rootFsId"), - RootFsLabel: db.GetStrPtr("rootFsLabel"), - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: i.PhoneHomeEnabled, - Status: OperatingSystemStatusPending, - CreatedBy: i.CreatedBy, + Name: i.Name, + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: i.InfrastructureProviderID, + TenantID: i.TenantID, + Version: db.GetStrPtr("version"), + OsType: "ipxe", + ImageURL: db.GetStrPtr("imageURL"), + ImageSHA: db.GetStrPtr("imageSHA"), + ImageAuthType: db.GetStrPtr("imageAuthType"), + ImageAuthToken: db.GetStrPtr("imageAuthToken"), + ImageDisk: db.GetStrPtr("imageDisk"), + RootFsId: db.GetStrPtr("rootFsId"), + RootFsLabel: db.GetStrPtr("rootFsLabel"), + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: i.PhoneHomeEnabled, + Status: OperatingSystemStatusPending, + CreatedBy: i.CreatedBy, }, ) assert.Equal(t, tc.expectError, err != nil) @@ -218,53 +217,50 @@ func TestOperatingSystemSQLDAO_GetByID(t *testing.T) { tenant := testOperatingSystemBuildTenant(t, dbSession, "testTenant") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() os1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "test1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "ipxe", - ImageURL: db.GetStrPtr("imageURL"), - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "test1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant.ID, + Version: db.GetStrPtr("version"), + OsType: "ipxe", + ImageURL: db.GetStrPtr("imageURL"), + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1) os2, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "test2", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: nil, - TenantID: nil, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "image", - ImageURL: db.GetStrPtr("imageURL"), - ImageSHA: db.GetStrPtr("imageSHA"), - ImageAuthType: db.GetStrPtr("imageAuthType"), - ImageAuthToken: db.GetStrPtr("imageAuthToken"), - ImageDisk: db.GetStrPtr("imageDisk"), - RootFsId: db.GetStrPtr("rootFsId"), - RootFsLabel: db.GetStrPtr("rootFsLabel"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "test2", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: nil, + TenantID: nil, + Version: db.GetStrPtr("version"), + OsType: "image", + ImageURL: db.GetStrPtr("imageURL"), + ImageSHA: db.GetStrPtr("imageSHA"), + ImageAuthType: db.GetStrPtr("imageAuthType"), + ImageAuthToken: db.GetStrPtr("imageAuthToken"), + ImageDisk: db.GetStrPtr("imageDisk"), + RootFsId: db.GetStrPtr("rootFsId"), + RootFsLabel: db.GetStrPtr("rootFsLabel"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os2) @@ -388,22 +384,21 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { if i%2 == 0 { os, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: fmt.Sprintf("os-%v", i), - Description: db.GetStrPtr("Test Description"), - Org: tenant1.Org, - InfrastructureProviderID: nil, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: db.GetStrPtr("imageURL"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: fmt.Sprintf("os-%v", i), + Description: db.GetStrPtr("Test Description"), + Org: tenant1.Org, + InfrastructureProviderID: nil, + TenantID: &tenant1.ID, + Version: db.GetStrPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: db.GetStrPtr("imageURL"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) ossTenant1 = append(ossTenant1, *os) @@ -420,23 +415,22 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { } else { _, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: fmt.Sprintf("os-%v", i), - Description: db.GetStrPtr("description"), - Org: tenant2.Org, - InfrastructureProviderID: nil, - TenantID: &tenant2.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: OperatingSystemTypeIPXE, - ImageURL: db.GetStrPtr("iPXE"), - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: false, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: fmt.Sprintf("os-%v", i), + Description: db.GetStrPtr("description"), + Org: tenant2.Org, + InfrastructureProviderID: nil, + TenantID: &tenant2.ID, + Version: db.GetStrPtr("version"), + OsType: OperatingSystemTypeIPXE, + ImageURL: db.GetStrPtr("iPXE"), + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: false, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) } @@ -449,72 +443,69 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { ossasSite2 := []OperatingSystemSiteAssociation{} ossasSite3 := []OperatingSystemSiteAssociation{} - joinIpxeOss := []OperatingSystem{} + joinIpxeScripts := []OperatingSystem{} // iPXE image 1 os, _ := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "ipxe-os-1", - Description: db.GetStrPtr("description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: OperatingSystemTypeIPXE, - ImageURL: db.GetStrPtr("iPXE"), - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: false, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "ipxe-os-1", + Description: db.GetStrPtr("description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: db.GetStrPtr("version"), + OsType: OperatingSystemTypeIPXE, + ImageURL: db.GetStrPtr("iPXE"), + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: false, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) - joinIpxeOss = append(joinIpxeOss, *os) + joinIpxeScripts = append(joinIpxeScripts, *os) // iPXE image 2 os, _ = ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "ipxe-os-2", - Description: db.GetStrPtr("description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: OperatingSystemTypeIPXE, - ImageURL: db.GetStrPtr("iPXE"), - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: false, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "ipxe-os-2", + Description: db.GetStrPtr("description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: db.GetStrPtr("version"), + OsType: OperatingSystemTypeIPXE, + ImageURL: db.GetStrPtr("iPXE"), + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: false, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) - joinIpxeOss = append(joinIpxeOss, *os) + joinIpxeScripts = append(joinIpxeScripts, *os) // OS Image 1 for site2 os, _ = ossd.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "image-os-1", - Description: db.GetStrPtr("Test Description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: db.GetStrPtr("imageURL"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "image-os-1", + Description: db.GetStrPtr("Test Description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: db.GetStrPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: db.GetStrPtr("imageURL"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) ossa, _ := ossaDAO.Create(ctx, nil, OperatingSystemSiteAssociationCreateInput{ OperatingSystemID: os.ID, @@ -526,22 +517,21 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { // OS Image 2 for site2 os, _ = ossd.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "image-os-2", - Description: db.GetStrPtr("Test Description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: db.GetStrPtr("imageURL"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "image-os-2", + Description: db.GetStrPtr("Test Description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: db.GetStrPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: db.GetStrPtr("imageURL"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) ossa, _ = ossaDAO.Create(ctx, nil, OperatingSystemSiteAssociationCreateInput{ OperatingSystemID: os.ID, @@ -553,22 +543,21 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { // OS Image 3 for site3 os, _ = ossd.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "image-os-3", - Description: db.GetStrPtr("Test Description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: db.GetStrPtr("imageURL"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "image-os-3", + Description: db.GetStrPtr("Test Description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: db.GetStrPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: db.GetStrPtr("imageURL"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) ossa, _ = ossaDAO.Create(ctx, nil, OperatingSystemSiteAssociationCreateInput{ OperatingSystemID: os.ID, @@ -781,7 +770,7 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { siteIDs: []uuid.UUID{site.ID}, searchQuery: nil, expectedCount: paginator.DefaultLimit, - expectedTotal: db.GetIntPtr(totalCount + len(joinIpxeOss)), + expectedTotal: db.GetIntPtr(totalCount + len(joinIpxeScripts)), expectedError: false, }, { @@ -836,8 +825,8 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { tenantIDs: nil, osNames: nil, osTypes: []string{OperatingSystemTypeIPXE}, - expectedCount: totalCount/2 + len(joinIpxeOss), - expectedTotal: db.GetIntPtr(totalCount/2 + len(joinIpxeOss)), + expectedCount: totalCount/2 + len(joinIpxeScripts), + expectedTotal: db.GetIntPtr(totalCount/2 + len(joinIpxeScripts)), expectedError: false, }, { @@ -855,7 +844,7 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { ipID: nil, tenantIDs: nil, osNames: nil, - osTypes: []string{OperatingSystemTypeImage, OperatingSystemTypeIPXE}, + osTypes: []string{OperatingSystemTypeIPXE, OperatingSystemTypeImage}, expectedCount: paginator.DefaultLimit, expectedTotal: db.GetIntPtr(totalCount + testJoinCount), expectedError: false, @@ -918,8 +907,8 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { siteIDs: []uuid.UUID{site3.ID}, osTypes: nil, searchQuery: nil, - expectedCount: len(joinIpxeOss) + len(ossasSite3), - expectedTotal: db.GetIntPtr(len(joinIpxeOss) + len(ossasSite3)), + expectedCount: len(joinIpxeScripts) + len(ossasSite3), + expectedTotal: db.GetIntPtr(len(joinIpxeScripts) + len(ossasSite3)), expectedError: false, }, { @@ -994,74 +983,69 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { tenant2 := testOperatingSystemBuildTenant(t, dbSession, "testTenant2") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() - updatedUUID := uuid.New() os1tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "ipxe", - ImageURL: db.GetStrPtr("imageURL"), - ImageSHA: db.GetStrPtr("imageSHA"), - ImageAuthType: db.GetStrPtr("imageAuthType"), - ImageAuthToken: db.GetStrPtr("imageAuthToken"), - ImageDisk: db.GetStrPtr("imageDisk"), - RootFsId: db.GetStrPtr("rootFsId"), - RootFsLabel: db.GetStrPtr("rootFsLabel"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: db.GetStrPtr("version"), + OsType: "ipxe", + ImageURL: db.GetStrPtr("imageURL"), + ImageSHA: db.GetStrPtr("imageSHA"), + ImageAuthType: db.GetStrPtr("imageAuthType"), + ImageAuthToken: db.GetStrPtr("imageAuthToken"), + ImageDisk: db.GetStrPtr("imageDisk"), + RootFsId: db.GetStrPtr("rootFsId"), + RootFsLabel: db.GetStrPtr("rootFsLabel"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant1) os2tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os2tenant1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "ipxe", - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os2tenant1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: db.GetStrPtr("version"), + OsType: "ipxe", + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os2tenant1) os1tenant2, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant2.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "ipxe", - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant2.ID, + Version: db.GetStrPtr("version"), + OsType: "ipxe", + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant2) @@ -1079,234 +1063,225 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { desc string os *OperatingSystem - paramName *string - paramDescription *string - paramOrg *string - paramInfrastructureProviderID *uuid.UUID - paramTenantID *uuid.UUID - paramControllerOperatingSystemID *uuid.UUID - paramVersion *string - paramType *string - paramImageURL *string - paramImageSHA *string - paramImageAuthType *string - paramImageAuthToken *string - paramImageDisk *string - paramRootFsID *string - paramRootFsLabel *string - paramIpxeScript *string - paramUserData *string - paramIsCloudInit *bool - paramAllowOverride *bool - paramEnableBlockStorage *bool - paramPhoneHomeEnabled *bool - paramIsActive *bool - paramDeactivationNote *string - paramStatus *string - - expectedName *string - expectedDescription *string - expectedOrg *string - expectedInfrastructureProviderID *uuid.UUID - expectedTenantID *uuid.UUID - expectedControllerOperatingSystemID *uuid.UUID - expectedVersion *string - expectedType *string - expectedImageURL *string - expectedImageSHA *string - expectedImageAuthType *string - expectedImageAuthToken *string - expectedImageDisk *string - expectedRootFsID *string - expectedRootFsLabel *string - expectedIpxeScript *string - expectedUserData *string - expectedIsCloudInit *bool - expectedAllowOverride *bool - expectedEnableBlockStorage *bool - expectPhoneHomeEnabled *bool - expectedIsActive *bool - expectedDeactivationNote *string - expectedStatus *string - verifyChildSpanner bool + paramName *string + paramDescription *string + paramOrg *string + paramInfrastructureProviderID *uuid.UUID + paramTenantID *uuid.UUID + paramVersion *string + paramType *string + paramImageURL *string + paramImageSHA *string + paramImageAuthType *string + paramImageAuthToken *string + paramImageDisk *string + paramRootFsID *string + paramRootFsLabel *string + paramIpxeScript *string + paramUserData *string + paramIsCloudInit *bool + paramAllowOverride *bool + paramEnableBlockStorage *bool + paramPhoneHomeEnabled *bool + paramIsActive *bool + paramDeactivationNote *string + paramStatus *string + + expectedName *string + expectedDescription *string + expectedOrg *string + expectedInfrastructureProviderID *uuid.UUID + expectedTenantID *uuid.UUID + expectedVersion *string + expectedType *string + expectedImageURL *string + expectedImageSHA *string + expectedImageAuthType *string + expectedImageAuthToken *string + expectedImageDisk *string + expectedRootFsID *string + expectedRootFsLabel *string + expectedIpxeScript *string + expectedUserData *string + expectedIsCloudInit *bool + expectedAllowOverride *bool + expectedEnableBlockStorage *bool + expectPhoneHomeEnabled *bool + expectedIsActive *bool + expectedDeactivationNote *string + expectedStatus *string + verifyChildSpanner bool }{ { desc: "can update string fields: name, description, org, version, imageurl, imageSHA, imageAuthType, imageAuthToken, imageDisk, rootFsID, rootFsLabel, ipxescript, userdata, status", os: os1tenant1, - paramName: db.GetStrPtr("updatedName"), - paramDescription: db.GetStrPtr("updatedDescription"), - paramOrg: db.GetStrPtr("updatedOrg"), - paramInfrastructureProviderID: nil, - paramTenantID: nil, - paramControllerOperatingSystemID: nil, - paramVersion: db.GetStrPtr("updatedVersion"), - paramType: db.GetStrPtr("updatedType"), - paramImageURL: db.GetStrPtr("updatedImageURL"), - paramImageSHA: db.GetStrPtr("updatedImageSHA"), - paramImageAuthType: db.GetStrPtr("updatedImageAuthType"), - paramImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), - paramImageDisk: db.GetStrPtr("updatedImageDisk"), - paramRootFsID: db.GetStrPtr("updatedRootFsID"), - paramRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), - paramIpxeScript: db.GetStrPtr("updatedIpxeScript"), - paramUserData: db.GetStrPtr("updatedUserData"), - paramIsCloudInit: nil, - paramAllowOverride: nil, - paramEnableBlockStorage: nil, - paramPhoneHomeEnabled: nil, - paramStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), - - expectedName: db.GetStrPtr("updatedName"), - expectedDescription: db.GetStrPtr("updatedDescription"), - expectedOrg: db.GetStrPtr("updatedOrg"), - expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, - expectedTenantID: os1tenant1.TenantID, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: db.GetStrPtr("updatedVersion"), - expectedType: db.GetStrPtr("updatedType"), - expectedImageURL: db.GetStrPtr("updatedImageURL"), - expectedImageSHA: db.GetStrPtr("updatedImageSHA"), - expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), - expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), - expectedImageDisk: db.GetStrPtr("updatedImageDisk"), - expectedRootFsID: db.GetStrPtr("updatedRootFsID"), - expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), - expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), - expectedUserData: db.GetStrPtr("updatedUserData"), - expectedIsCloudInit: &os1tenant1.IsCloudInit, - expectedAllowOverride: &os1tenant1.AllowOverride, - expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, - expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, - expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), - verifyChildSpanner: true, + paramName: db.GetStrPtr("updatedName"), + paramDescription: db.GetStrPtr("updatedDescription"), + paramOrg: db.GetStrPtr("updatedOrg"), + paramInfrastructureProviderID: nil, + paramTenantID: nil, + paramVersion: db.GetStrPtr("updatedVersion"), + paramType: db.GetStrPtr("updatedType"), + paramImageURL: db.GetStrPtr("updatedImageURL"), + paramImageSHA: db.GetStrPtr("updatedImageSHA"), + paramImageAuthType: db.GetStrPtr("updatedImageAuthType"), + paramImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), + paramImageDisk: db.GetStrPtr("updatedImageDisk"), + paramRootFsID: db.GetStrPtr("updatedRootFsID"), + paramRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), + paramIpxeScript: db.GetStrPtr("updatedIpxeScript"), + paramUserData: db.GetStrPtr("updatedUserData"), + paramIsCloudInit: nil, + paramAllowOverride: nil, + paramEnableBlockStorage: nil, + paramPhoneHomeEnabled: nil, + paramStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), + + expectedName: db.GetStrPtr("updatedName"), + expectedDescription: db.GetStrPtr("updatedDescription"), + expectedOrg: db.GetStrPtr("updatedOrg"), + expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, + expectedTenantID: os1tenant1.TenantID, + expectedVersion: db.GetStrPtr("updatedVersion"), + expectedType: db.GetStrPtr("updatedType"), + expectedImageURL: db.GetStrPtr("updatedImageURL"), + expectedImageSHA: db.GetStrPtr("updatedImageSHA"), + expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), + expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), + expectedImageDisk: db.GetStrPtr("updatedImageDisk"), + expectedRootFsID: db.GetStrPtr("updatedRootFsID"), + expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), + expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), + expectedUserData: db.GetStrPtr("updatedUserData"), + expectedIsCloudInit: &os1tenant1.IsCloudInit, + expectedAllowOverride: &os1tenant1.AllowOverride, + expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, + expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, + expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), + verifyChildSpanner: true, }, { desc: "can update uuid fields: infrastructureproviderid, tenantid, controlleroperatingsystemid", os: os1tenant1, - paramName: nil, - paramDescription: nil, - paramOrg: nil, - paramInfrastructureProviderID: &updatedIP.ID, - paramTenantID: &updatedTenant.ID, - paramControllerOperatingSystemID: &updatedUUID, - paramVersion: nil, - paramType: nil, - paramImageURL: nil, - paramImageSHA: nil, - paramImageAuthType: nil, - paramImageAuthToken: nil, - paramImageDisk: nil, - paramRootFsID: nil, - paramRootFsLabel: nil, - paramIpxeScript: nil, - paramUserData: nil, - paramIsCloudInit: nil, - paramAllowOverride: nil, - paramEnableBlockStorage: nil, - paramPhoneHomeEnabled: nil, - paramStatus: nil, - - expectedName: db.GetStrPtr("updatedName"), - expectedDescription: db.GetStrPtr("updatedDescription"), - expectedOrg: db.GetStrPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: db.GetStrPtr("updatedVersion"), - expectedType: db.GetStrPtr("updatedType"), - expectedImageURL: db.GetStrPtr("updatedImageURL"), - expectedImageSHA: db.GetStrPtr("updatedImageSHA"), - expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), - expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), - expectedImageDisk: db.GetStrPtr("updatedImageDisk"), - expectedRootFsID: db.GetStrPtr("updatedRootFsID"), - expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), - expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), - expectedUserData: db.GetStrPtr("updatedUserData"), - expectedIsCloudInit: &os1tenant1.IsCloudInit, - expectedAllowOverride: &os1tenant1.AllowOverride, - expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, - expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, - expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), + paramName: nil, + paramDescription: nil, + paramOrg: nil, + paramInfrastructureProviderID: &updatedIP.ID, + paramTenantID: &updatedTenant.ID, + paramVersion: nil, + paramType: nil, + paramImageURL: nil, + paramImageSHA: nil, + paramImageAuthType: nil, + paramImageAuthToken: nil, + paramImageDisk: nil, + paramRootFsID: nil, + paramRootFsLabel: nil, + paramIpxeScript: nil, + paramUserData: nil, + paramIsCloudInit: nil, + paramAllowOverride: nil, + paramEnableBlockStorage: nil, + paramPhoneHomeEnabled: nil, + paramStatus: nil, + + expectedName: db.GetStrPtr("updatedName"), + expectedDescription: db.GetStrPtr("updatedDescription"), + expectedOrg: db.GetStrPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: db.GetStrPtr("updatedVersion"), + expectedType: db.GetStrPtr("updatedType"), + expectedImageURL: db.GetStrPtr("updatedImageURL"), + expectedImageSHA: db.GetStrPtr("updatedImageSHA"), + expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), + expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), + expectedImageDisk: db.GetStrPtr("updatedImageDisk"), + expectedRootFsID: db.GetStrPtr("updatedRootFsID"), + expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), + expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), + expectedUserData: db.GetStrPtr("updatedUserData"), + expectedIsCloudInit: &os1tenant1.IsCloudInit, + expectedAllowOverride: &os1tenant1.AllowOverride, + expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, + expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, + expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), }, { desc: "can update bool fields: iscloudinit, allowcloudinit, isblockstorage", os: os1tenant1, - paramName: nil, - paramDescription: nil, - paramOrg: nil, - paramInfrastructureProviderID: nil, - paramTenantID: nil, - paramControllerOperatingSystemID: nil, - paramVersion: nil, - paramType: nil, - paramImageURL: nil, - paramImageSHA: nil, - paramImageAuthType: nil, - paramImageAuthToken: nil, - paramImageDisk: nil, - paramRootFsID: nil, - paramRootFsLabel: nil, - paramIpxeScript: nil, - paramUserData: nil, - paramIsCloudInit: &updatedIsCloudInit, - paramAllowOverride: &updatedAllowOverride, - paramEnableBlockStorage: &updatedEnableBlockStorage, - paramPhoneHomeEnabled: &updatedPhoneHomeEnabled, - paramStatus: nil, - - expectedName: db.GetStrPtr("updatedName"), - expectedDescription: db.GetStrPtr("updatedDescription"), - expectedOrg: db.GetStrPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: db.GetStrPtr("updatedVersion"), - expectedType: db.GetStrPtr("updatedType"), - expectedImageURL: db.GetStrPtr("updatedImageURL"), - expectedImageSHA: db.GetStrPtr("updatedImageSHA"), - expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), - expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), - expectedImageDisk: db.GetStrPtr("updatedImageDisk"), - expectedRootFsID: db.GetStrPtr("updatedRootFsID"), - expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), - expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), - expectedUserData: db.GetStrPtr("updatedUserData"), - expectedIsCloudInit: &updatedIsCloudInit, - expectedAllowOverride: &updatedAllowOverride, - expectedEnableBlockStorage: &updatedEnableBlockStorage, - expectPhoneHomeEnabled: &updatedEnableBlockStorage, - expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), + paramName: nil, + paramDescription: nil, + paramOrg: nil, + paramInfrastructureProviderID: nil, + paramTenantID: nil, + paramVersion: nil, + paramType: nil, + paramImageURL: nil, + paramImageSHA: nil, + paramImageAuthType: nil, + paramImageAuthToken: nil, + paramImageDisk: nil, + paramRootFsID: nil, + paramRootFsLabel: nil, + paramIpxeScript: nil, + paramUserData: nil, + paramIsCloudInit: &updatedIsCloudInit, + paramAllowOverride: &updatedAllowOverride, + paramEnableBlockStorage: &updatedEnableBlockStorage, + paramPhoneHomeEnabled: &updatedPhoneHomeEnabled, + paramStatus: nil, + + expectedName: db.GetStrPtr("updatedName"), + expectedDescription: db.GetStrPtr("updatedDescription"), + expectedOrg: db.GetStrPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: db.GetStrPtr("updatedVersion"), + expectedType: db.GetStrPtr("updatedType"), + expectedImageURL: db.GetStrPtr("updatedImageURL"), + expectedImageSHA: db.GetStrPtr("updatedImageSHA"), + expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), + expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), + expectedImageDisk: db.GetStrPtr("updatedImageDisk"), + expectedRootFsID: db.GetStrPtr("updatedRootFsID"), + expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), + expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), + expectedUserData: db.GetStrPtr("updatedUserData"), + expectedIsCloudInit: &updatedIsCloudInit, + expectedAllowOverride: &updatedAllowOverride, + expectedEnableBlockStorage: &updatedEnableBlockStorage, + expectPhoneHomeEnabled: &updatedEnableBlockStorage, + expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), }, { desc: "ok when no fields are updated", os: os1tenant1, - expectedName: db.GetStrPtr("updatedName"), - expectedDescription: db.GetStrPtr("updatedDescription"), - expectedOrg: db.GetStrPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: db.GetStrPtr("updatedVersion"), - expectedType: db.GetStrPtr("updatedType"), - expectedImageURL: db.GetStrPtr("updatedImageURL"), - expectedImageSHA: db.GetStrPtr("updatedImageSHA"), - expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), - expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), - expectedImageDisk: db.GetStrPtr("updatedImageDisk"), - expectedRootFsID: db.GetStrPtr("updatedRootFsID"), - expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), - expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), - expectedUserData: db.GetStrPtr("updatedUserData"), - expectedIsCloudInit: &updatedIsCloudInit, - expectedAllowOverride: &updatedAllowOverride, - expectedEnableBlockStorage: &updatedEnableBlockStorage, - expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, - expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), + expectedName: db.GetStrPtr("updatedName"), + expectedDescription: db.GetStrPtr("updatedDescription"), + expectedOrg: db.GetStrPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: db.GetStrPtr("updatedVersion"), + expectedType: db.GetStrPtr("updatedType"), + expectedImageURL: db.GetStrPtr("updatedImageURL"), + expectedImageSHA: db.GetStrPtr("updatedImageSHA"), + expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), + expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), + expectedImageDisk: db.GetStrPtr("updatedImageDisk"), + expectedRootFsID: db.GetStrPtr("updatedRootFsID"), + expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), + expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), + expectedUserData: db.GetStrPtr("updatedUserData"), + expectedIsCloudInit: &updatedIsCloudInit, + expectedAllowOverride: &updatedAllowOverride, + expectedEnableBlockStorage: &updatedEnableBlockStorage, + expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, + expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), }, { desc: "can update isActive from true to false", @@ -1314,60 +1289,58 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { paramIsActive: &updatedIsActive, paramDeactivationNote: &updatedDeactivationNote, - expectedName: db.GetStrPtr("updatedName"), - expectedDescription: db.GetStrPtr("updatedDescription"), - expectedOrg: db.GetStrPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: db.GetStrPtr("updatedVersion"), - expectedType: db.GetStrPtr("updatedType"), - expectedImageURL: db.GetStrPtr("updatedImageURL"), - expectedImageSHA: db.GetStrPtr("updatedImageSHA"), - expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), - expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), - expectedImageDisk: db.GetStrPtr("updatedImageDisk"), - expectedRootFsID: db.GetStrPtr("updatedRootFsID"), - expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), - expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), - expectedUserData: db.GetStrPtr("updatedUserData"), - expectedIsCloudInit: &updatedIsCloudInit, - expectedAllowOverride: &updatedAllowOverride, - expectedEnableBlockStorage: &updatedEnableBlockStorage, - expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, - expectedIsActive: &updatedIsActive, - expectedDeactivationNote: &updatedDeactivationNote, - expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), + expectedName: db.GetStrPtr("updatedName"), + expectedDescription: db.GetStrPtr("updatedDescription"), + expectedOrg: db.GetStrPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: db.GetStrPtr("updatedVersion"), + expectedType: db.GetStrPtr("updatedType"), + expectedImageURL: db.GetStrPtr("updatedImageURL"), + expectedImageSHA: db.GetStrPtr("updatedImageSHA"), + expectedImageAuthType: db.GetStrPtr("updatedImageAuthType"), + expectedImageAuthToken: db.GetStrPtr("updatedImageAuthToken"), + expectedImageDisk: db.GetStrPtr("updatedImageDisk"), + expectedRootFsID: db.GetStrPtr("updatedRootFsID"), + expectedRootFsLabel: db.GetStrPtr("updatedRootFsLabel"), + expectedIpxeScript: db.GetStrPtr("updatedIpxeScript"), + expectedUserData: db.GetStrPtr("updatedUserData"), + expectedIsCloudInit: &updatedIsCloudInit, + expectedAllowOverride: &updatedAllowOverride, + expectedEnableBlockStorage: &updatedEnableBlockStorage, + expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, + expectedIsActive: &updatedIsActive, + expectedDeactivationNote: &updatedDeactivationNote, + expectedStatus: db.GetStrPtr(OperatingSystemStatusProvisioning), }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { input := OperatingSystemUpdateInput{ - OperatingSystemId: tc.os.ID, - Name: tc.paramName, - Description: tc.paramDescription, - Org: tc.paramOrg, - InfrastructureProviderID: tc.paramInfrastructureProviderID, - TenantID: tc.paramTenantID, - ControllerOperatingSystemID: tc.paramControllerOperatingSystemID, - Version: tc.paramVersion, - OsType: tc.paramType, - ImageURL: tc.paramImageURL, - ImageSHA: tc.paramImageSHA, - ImageAuthType: tc.paramImageAuthType, - ImageAuthToken: tc.paramImageAuthToken, - ImageDisk: tc.paramImageDisk, - RootFsId: tc.paramRootFsID, - RootFsLabel: tc.paramRootFsLabel, - IpxeScript: tc.paramIpxeScript, - UserData: tc.paramUserData, - IsCloudInit: tc.paramIsCloudInit, - AllowOverride: tc.paramAllowOverride, - EnableBlockStorage: tc.paramEnableBlockStorage, - PhoneHomeEnabled: tc.paramPhoneHomeEnabled, - IsActive: tc.paramIsActive, - DeactivationNote: tc.paramDeactivationNote, - Status: tc.paramStatus, + OperatingSystemId: tc.os.ID, + Name: tc.paramName, + Description: tc.paramDescription, + Org: tc.paramOrg, + InfrastructureProviderID: tc.paramInfrastructureProviderID, + TenantID: tc.paramTenantID, + Version: tc.paramVersion, + OsType: tc.paramType, + ImageURL: tc.paramImageURL, + ImageSHA: tc.paramImageSHA, + ImageAuthType: tc.paramImageAuthType, + ImageAuthToken: tc.paramImageAuthToken, + ImageDisk: tc.paramImageDisk, + RootFsId: tc.paramRootFsID, + RootFsLabel: tc.paramRootFsLabel, + IpxeScript: tc.paramIpxeScript, + UserData: tc.paramUserData, + IsCloudInit: tc.paramIsCloudInit, + AllowOverride: tc.paramAllowOverride, + EnableBlockStorage: tc.paramEnableBlockStorage, + PhoneHomeEnabled: tc.paramPhoneHomeEnabled, + IsActive: tc.paramIsActive, + DeactivationNote: tc.paramDeactivationNote, + Status: tc.paramStatus, } got, err := ossd.Update(ctx, nil, input) assert.Nil(t, err) @@ -1388,10 +1361,6 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { if tc.expectedTenantID != nil { assert.Equal(t, *tc.expectedTenantID, *got.TenantID) } - assert.Equal(t, tc.expectedControllerOperatingSystemID == nil, got.ControllerOperatingSystemID == nil) - if tc.expectedControllerOperatingSystemID != nil { - assert.Equal(t, *tc.expectedControllerOperatingSystemID, *got.ControllerOperatingSystemID) - } assert.Equal(t, tc.expectedVersion == nil, got.Version == nil) if tc.expectedVersion != nil { assert.Equal(t, *tc.expectedVersion, *got.Version) @@ -1468,80 +1437,76 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { tenant2 := testOperatingSystemBuildTenant(t, dbSession, "testTenant2") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() os1tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "image", - ImageURL: db.GetStrPtr("imageURL"), - ImageSHA: db.GetStrPtr("imageSHA"), - ImageAuthType: db.GetStrPtr("imageAuthType"), - ImageAuthToken: db.GetStrPtr("imageAuthToken"), - ImageDisk: db.GetStrPtr("imageDisk"), - RootFsId: db.GetStrPtr("rootFsId"), - RootFsLabel: db.GetStrPtr("rootFsLabel"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: db.GetStrPtr("version"), + OsType: "image", + ImageURL: db.GetStrPtr("imageURL"), + ImageSHA: db.GetStrPtr("imageSHA"), + ImageAuthType: db.GetStrPtr("imageAuthType"), + ImageAuthToken: db.GetStrPtr("imageAuthToken"), + ImageDisk: db.GetStrPtr("imageDisk"), + RootFsId: db.GetStrPtr("rootFsId"), + RootFsLabel: db.GetStrPtr("rootFsLabel"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant1) os2tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os2tenant1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "ipxe", - ImageURL: db.GetStrPtr("imageURL"), - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os2tenant1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: db.GetStrPtr("version"), + OsType: "ipxe", + ImageURL: db.GetStrPtr("imageURL"), + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os2tenant1) os1tenant2, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant2.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "image", - ImageURL: db.GetStrPtr("imageURL"), - ImageSHA: db.GetStrPtr("imageSHA"), - ImageAuthType: db.GetStrPtr("imageAuthType"), - ImageAuthToken: db.GetStrPtr("imageAuthToken"), - ImageDisk: db.GetStrPtr("imageDisk"), - RootFsId: db.GetStrPtr("rootFsId"), - RootFsLabel: db.GetStrPtr("rootFsLabel"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant2.ID, + Version: db.GetStrPtr("version"), + OsType: "image", + ImageURL: db.GetStrPtr("imageURL"), + ImageSHA: db.GetStrPtr("imageSHA"), + ImageAuthType: db.GetStrPtr("imageAuthType"), + ImageAuthToken: db.GetStrPtr("imageAuthToken"), + ImageDisk: db.GetStrPtr("imageDisk"), + RootFsId: db.GetStrPtr("rootFsId"), + RootFsLabel: db.GetStrPtr("rootFsLabel"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant2) @@ -1550,39 +1515,37 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { _, _, ctx = testCommonTraceProviderSetup(t, ctx) tests := []struct { - desc string - os *OperatingSystem - paramDescription bool - paramInfrastructureProviderID bool - paramTenantID bool - paramControllerOperatingSystemID bool - paramVersion bool - paramImageURL bool - paramImageSHA bool - paramImageAuthType bool - paramImageAuthToken bool - paramImageDisk bool - paramRootFsID bool - paramRootFsLabel bool - paramIpxeScript bool - paramUserData bool - - expectedDescription *string - expectedInfrastructureProviderID *uuid.UUID - expectedTenantID *uuid.UUID - expectedControllerOperatingSystemID *uuid.UUID - expectedVersion *string - expectedImageURL *string - expectedImageSHA *string - expectedImageAuthType *string - expectedImageAuthToken *string - expectedImageDisk *string - expectedRootFsID *string - expectedRootFsLabel *string - expectedIpxeScript *string - expectedUserData *string - expectedUpdate bool - verifyChildSpanner bool + desc string + os *OperatingSystem + paramDescription bool + paramInfrastructureProviderID bool + paramTenantID bool + paramVersion bool + paramImageURL bool + paramImageSHA bool + paramImageAuthType bool + paramImageAuthToken bool + paramImageDisk bool + paramRootFsID bool + paramRootFsLabel bool + paramIpxeScript bool + paramUserData bool + + expectedDescription *string + expectedInfrastructureProviderID *uuid.UUID + expectedTenantID *uuid.UUID + expectedVersion *string + expectedImageURL *string + expectedImageSHA *string + expectedImageAuthType *string + expectedImageAuthToken *string + expectedImageDisk *string + expectedRootFsID *string + expectedRootFsLabel *string + expectedIpxeScript *string + expectedUserData *string + expectedUpdate bool + verifyChildSpanner bool }{ { desc: "can clear description", @@ -1590,22 +1553,21 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramDescription: true, - expectedDescription: nil, - expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, - expectedTenantID: os1tenant1.TenantID, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, - verifyChildSpanner: true, + expectedDescription: nil, + expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, + expectedTenantID: os1tenant1.TenantID, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, + verifyChildSpanner: true, }, { desc: "can clear InfrastructureProviderID", @@ -1613,21 +1575,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramInfrastructureProviderID: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: os1tenant1.TenantID, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: os1tenant1.TenantID, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear TenantID", @@ -1635,43 +1596,39 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramTenantID: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, - }, - { - desc: "can clear ControllerOperatingSystemID", + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, + }, + { + desc: "can run clear with no flags set", os: os1tenant1, - paramControllerOperatingSystemID: true, - - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear version", @@ -1679,21 +1636,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramVersion: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageURL", @@ -1701,21 +1657,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageURL: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageSHA", @@ -1723,21 +1678,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageSHA: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageAuthType", @@ -1745,21 +1699,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageAuthType: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageAuthToken", @@ -1767,21 +1720,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageAuthToken: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageDisk", @@ -1789,21 +1741,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageDisk: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear RootFsId", @@ -1811,21 +1762,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramRootFsID: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear RootFsLabel", @@ -1833,21 +1783,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramRootFsLabel: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: nil, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: nil, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear IpxeScript", @@ -1855,15 +1804,14 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramIpxeScript: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedIpxeScript: nil, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedIpxeScript: nil, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear UserData", @@ -1871,96 +1819,91 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramUserData: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: nil, - expectedIpxeScript: nil, - expectedUserData: nil, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: nil, + expectedIpxeScript: nil, + expectedUserData: nil, + expectedUpdate: true, }, { desc: "can clear multiple fields at once", os: os1tenant2, - paramDescription: true, - paramInfrastructureProviderID: true, - paramTenantID: true, - paramControllerOperatingSystemID: true, - paramVersion: true, - paramImageURL: true, - paramImageSHA: true, - paramImageAuthType: true, - paramImageAuthToken: true, - paramImageDisk: true, - paramRootFsID: true, - paramRootFsLabel: true, - paramIpxeScript: true, - paramUserData: true, - - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: nil, - expectedIpxeScript: nil, - expectedUserData: nil, - expectedUpdate: true, + paramDescription: true, + paramInfrastructureProviderID: true, + paramTenantID: true, + paramVersion: true, + paramImageURL: true, + paramImageSHA: true, + paramImageAuthType: true, + paramImageAuthToken: true, + paramImageDisk: true, + paramRootFsID: true, + paramRootFsLabel: true, + paramIpxeScript: true, + paramUserData: true, + + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: nil, + expectedIpxeScript: nil, + expectedUserData: nil, + expectedUpdate: true, }, { desc: "nop when no cleared fields are specified", os: os2tenant1, - expectedDescription: os2tenant1.Description, - expectedInfrastructureProviderID: os2tenant1.InfrastructureProviderID, - expectedTenantID: os2tenant1.TenantID, - expectedControllerOperatingSystemID: os2tenant1.ControllerOperatingSystemID, - expectedVersion: os2tenant1.Version, - expectedImageURL: os2tenant1.ImageURL, - expectedImageSHA: os2tenant1.ImageSHA, - expectedImageAuthType: os2tenant1.ImageAuthType, - expectedImageAuthToken: os2tenant1.ImageAuthToken, - expectedImageDisk: os2tenant1.ImageDisk, - expectedRootFsID: os2tenant1.RootFsID, - expectedRootFsLabel: os2tenant1.RootFsLabel, - expectedIpxeScript: os2tenant1.IpxeScript, - expectedUserData: os2tenant1.UserData, - expectedUpdate: false, + expectedDescription: os2tenant1.Description, + expectedInfrastructureProviderID: os2tenant1.InfrastructureProviderID, + expectedTenantID: os2tenant1.TenantID, + expectedVersion: os2tenant1.Version, + expectedImageURL: os2tenant1.ImageURL, + expectedImageSHA: os2tenant1.ImageSHA, + expectedImageAuthType: os2tenant1.ImageAuthType, + expectedImageAuthToken: os2tenant1.ImageAuthToken, + expectedImageDisk: os2tenant1.ImageDisk, + expectedRootFsID: os2tenant1.RootFsID, + expectedRootFsLabel: os2tenant1.RootFsLabel, + expectedIpxeScript: os2tenant1.IpxeScript, + expectedUserData: os2tenant1.UserData, + expectedUpdate: false, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { input := OperatingSystemClearInput{ - OperatingSystemId: tc.os.ID, - Description: tc.paramDescription, - InfrastructureProviderID: tc.paramInfrastructureProviderID, - TenantID: tc.paramTenantID, - ControllerOperatingSystemID: tc.paramControllerOperatingSystemID, - Version: tc.paramVersion, - ImageURL: tc.paramImageURL, - ImageSHA: tc.paramImageSHA, - ImageAuthType: tc.paramImageAuthType, - ImageAuthToken: tc.paramImageAuthToken, - ImageDisk: tc.paramImageDisk, - RootFsId: tc.paramRootFsID, - RootFsLabel: tc.paramRootFsLabel, - IpxeScript: tc.paramIpxeScript, - UserData: tc.paramUserData, + OperatingSystemId: tc.os.ID, + Description: tc.paramDescription, + InfrastructureProviderID: tc.paramInfrastructureProviderID, + TenantID: tc.paramTenantID, + Version: tc.paramVersion, + ImageURL: tc.paramImageURL, + ImageSHA: tc.paramImageSHA, + ImageAuthType: tc.paramImageAuthType, + ImageAuthToken: tc.paramImageAuthToken, + ImageDisk: tc.paramImageDisk, + RootFsId: tc.paramRootFsID, + RootFsLabel: tc.paramRootFsLabel, + IpxeScript: tc.paramIpxeScript, + UserData: tc.paramUserData, } tmp, err := ossd.Clear(ctx, nil, input) assert.Nil(t, err) @@ -1977,10 +1920,6 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { if tc.expectedTenantID != nil { assert.Equal(t, *tc.expectedTenantID, *tmp.TenantID) } - assert.Equal(t, tc.expectedControllerOperatingSystemID == nil, tmp.ControllerOperatingSystemID == nil) - if tc.expectedControllerOperatingSystemID != nil { - assert.Equal(t, *tc.expectedControllerOperatingSystemID, *tmp.ControllerOperatingSystemID) - } assert.Equal(t, tc.expectedVersion == nil, tmp.Version == nil) if tc.expectedVersion != nil { assert.Equal(t, *tc.expectedVersion, *tmp.Version) @@ -2044,26 +1983,24 @@ func TestOperatingSystemSQLDAO_Delete(t *testing.T) { tenant := testOperatingSystemBuildTenant(t, dbSession, "testTenant") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() os1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: db.GetStrPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: db.GetStrPtr("version"), - OsType: "ipxe", - ImageURL: db.GetStrPtr("imageURL"), - IpxeScript: db.GetStrPtr("ipxeScript"), - UserData: db.GetStrPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: db.GetStrPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant.ID, + Version: db.GetStrPtr("version"), + OsType: "ipxe", + ImageURL: db.GetStrPtr("imageURL"), + IpxeScript: db.GetStrPtr("ipxeScript"), + UserData: db.GetStrPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1) diff --git a/db/pkg/db/model/operatingsystemsiteassociation.go b/db/pkg/db/model/operatingsystemsiteassociation.go index 912fadb17..9ef130420 100644 --- a/db/pkg/db/model/operatingsystemsiteassociation.go +++ b/db/pkg/db/model/operatingsystemsiteassociation.go @@ -30,6 +30,8 @@ import ( "github.com/uptrace/bun" stracer "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/tracer" + + ws "github.com/NVIDIA/ncx-infra-controller-rest/workflow-schema/schema/site-agent/workflows/v1" ) var ( @@ -70,6 +72,14 @@ var ( OperatingSystemSiteAssociationStatusError: true, OperatingSystemSiteAssociationStatusDeleting: true, } + + OperatingSystemSiteAssociationStatusFromProtoMap = map[ws.TenantState]string{ + ws.TenantState_PROVISIONING: OperatingSystemSiteAssociationStatusSyncing, + ws.TenantState_READY: OperatingSystemSiteAssociationStatusSynced, + ws.TenantState_CONFIGURING: OperatingSystemSiteAssociationStatusSyncing, + ws.TenantState_TERMINATING: OperatingSystemSiteAssociationStatusDeleting, + ws.TenantState_FAILED: OperatingSystemSiteAssociationStatusError, + } ) // OperatingSystemSiteAssociation associates an OperatingSystem with different Sites @@ -83,6 +93,7 @@ type OperatingSystemSiteAssociation struct { Site *Site `bun:"rel:belongs-to,join:site_id=id"` Version *string `bun:"version"` Status string `bun:"status,notnull"` + ControllerState *string `bun:"controller_state"` IsMissingOnSite bool `bun:"is_missing_on_site,notnull"` Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` @@ -96,6 +107,7 @@ type OperatingSystemSiteAssociationCreateInput struct { SiteID uuid.UUID Version *string Status string + ControllerState *string CreatedBy uuid.UUID } @@ -106,6 +118,7 @@ type OperatingSystemSiteAssociationUpdateInput struct { SiteID *uuid.UUID Version *string Status *string + ControllerState *string IsMissingOnSite *bool } @@ -146,7 +159,7 @@ type OperatingSystemSiteAssociationDAO interface { // GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) // - GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, OperatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) + GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, operatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) // GetAll(ctx context.Context, tx *db.Tx, filter OperatingSystemSiteAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]OperatingSystemSiteAssociation, int, error) // @@ -181,6 +194,7 @@ func (ossasd OperatingSystemSiteAssociationSQLDAO) Create( SiteID: input.SiteID, Version: input.Version, Status: input.Status, + ControllerState: input.ControllerState, CreatedBy: input.CreatedBy, } @@ -229,19 +243,19 @@ func (ossasd OperatingSystemSiteAssociationSQLDAO) GetByID(ctx context.Context, // GetByOperatingSystemIDAndSiteID returns an OperatingSystemSiteAssociation by OperatingSystemID and SiteID // returns db.ErrDoesNotExist error if the record is not found -func (ossasd OperatingSystemSiteAssociationSQLDAO) GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, OperatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) { +func (ossasd OperatingSystemSiteAssociationSQLDAO) GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, operatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) { // Create a child span and set the attributes for current request ctx, OperatingSystemSiteAssociationDAOSpan := ossasd.tracerSpan.CreateChildInCurrentContext(ctx, "OperatingSystemSiteAssociationDAO.GetByOperatingSystemIDAndSiteID") if OperatingSystemSiteAssociationDAOSpan != nil { defer OperatingSystemSiteAssociationDAOSpan.End() - ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "operating_system_id", OperatingSystemID.String()) + ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "operating_system_id", operatingSystemID.String()) ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "site_id", siteID.String()) } ossa := &OperatingSystemSiteAssociation{} - query := db.GetIDB(tx, ossasd.dbSession).NewSelect().Model(ossa).Where("ossa.operating_system_id = ?", OperatingSystemID.String()).Where("ossa.site_id = ?", siteID.String()) + query := db.GetIDB(tx, ossasd.dbSession).NewSelect().Model(ossa).Where("ossa.operating_system_id = ?", operatingSystemID.String()).Where("ossa.site_id = ?", siteID.String()) for _, relation := range includeRelations { query = query.Relation(relation) @@ -413,6 +427,11 @@ func (ossasd OperatingSystemSiteAssociationSQLDAO) Update( updatedFields = append(updatedFields, "is_missing_on_site") ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "is_missing_on_site", *input.IsMissingOnSite) } + if input.ControllerState != nil { + ossa.ControllerState = input.ControllerState + updatedFields = append(updatedFields, "controller_state") + ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "controller_state", *input.ControllerState) + } if len(updatedFields) > 0 { updatedFields = append(updatedFields, "updated") diff --git a/db/pkg/migrations/20260306120000_ipxe_os_and_templates.go b/db/pkg/migrations/20260306120000_ipxe_os_and_templates.go new file mode 100644 index 000000000..31a344caf --- /dev/null +++ b/db/pkg/migrations/20260306120000_ipxe_os_and_templates.go @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/NVIDIA/ncx-infra-controller-rest/db/pkg/db/model" + "github.com/uptrace/bun" +) + +// 20260306120000_ipxe_os_and_templates +// +// Introduces the iPXE Template + OS Template support in REST: +// - Global `ipxe_template` table, keyed by the stable template UUID assigned +// by bare-metal-manager-core (the same UUID is used on both sides). +// - `ipxe_template_site_association` (ITSA) table tracking which sites +// currently report each global template. +// - iPXE-template related columns on `operating_system` and +// `operating_system_site_association`. +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + tx, terr := db.BeginTx(ctx, &sql.TxOptions{}) + if terr != nil { + handlePanic(terr, "failed to begin transaction") + } + + // ── iPXE Template table (global) ───────────────────────────────── + + _, err := tx.NewCreateTable().Model((*model.IpxeTemplate)(nil)).IfNotExists().Exec(ctx) + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE ipxe_template DROP CONSTRAINT IF EXISTS ipxe_template_name_key") + handleError(tx, err) + _, err = tx.Exec("ALTER TABLE ipxe_template ADD CONSTRAINT ipxe_template_name_key UNIQUE (name)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_name_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_name_idx ON ipxe_template(name)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_scope_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_scope_idx ON ipxe_template(scope)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_created_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_created_idx ON ipxe_template(created)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_updated_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_updated_idx ON ipxe_template(updated)") + handleError(tx, err) + + // ── iPXE Template ↔ Site association ───────────────────────────── + + _, err = tx.NewCreateTable().Model((*model.IpxeTemplateSiteAssociation)(nil)).IfNotExists().Exec(ctx) + handleError(tx, err) + + _, err = tx.Exec(` + ALTER TABLE ipxe_template_site_association + DROP CONSTRAINT IF EXISTS ipxe_template_site_association_template_id_site_id_key + `) + handleError(tx, err) + _, err = tx.Exec(` + ALTER TABLE ipxe_template_site_association + ADD CONSTRAINT ipxe_template_site_association_template_id_site_id_key + UNIQUE (ipxe_template_id, site_id) + `) + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS itsa_ipxe_template_id_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX itsa_ipxe_template_id_idx ON ipxe_template_site_association(ipxe_template_id)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS itsa_site_id_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX itsa_site_id_idx ON ipxe_template_site_association(site_id)") + handleError(tx, err) + + // ── Operating System: iPXE definition columns ──────────────────── + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_id TEXT NULL") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_parameters JSONB NULL") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_artifacts JSONB NULL") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_definition_hash TEXT NULL") + handleError(tx, err) + + // controller_operating_system_id is no longer needed: the primary key is the same on + // both sides, so drop the column and its index if they still exist. + _, err = tx.Exec("DROP INDEX IF EXISTS operating_system_controller_os_id_idx") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system DROP COLUMN IF EXISTS controller_operating_system_id") + handleError(tx, err) + + // ── Operating System: scope column ─────────────────────────────── + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_os_scope TEXT NULL") + handleError(tx, err) + + // ── Operating System Site Association: controller state ────────── + + _, err = tx.Exec("ALTER TABLE operating_system_site_association ADD COLUMN IF NOT EXISTS controller_state TEXT NULL") + handleError(tx, err) + + // ── Backfill ipxe_os_scope for existing iPXE-type OS records ──── + // + // Tenant-owned raw iPXE → Global (preserves legacy behavior: tenant + // can use it for any Instance at any accessible site). + // Provider-owned iPXE (from carbide-core inventory) → Local (single + // site, bidirectional sync). + // Image-type OS entries are left as NULL since scope does not apply. + + _, err = tx.Exec(` + UPDATE operating_system + SET ipxe_os_scope = 'Global' + WHERE ipxe_os_scope IS NULL + AND type = 'iPXE' + AND tenant_id IS NOT NULL + AND deleted IS NULL + `) + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [up migration] ") + return nil + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] No action taken") + return nil + }) +} diff --git a/docs/index.html b/docs/index.html index f9e785443..e9decdfcf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -444,7 +444,7 @@ -
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Name of the Org

query Parameters
siteId
string

Filter Operating Systems by Site ID. Can be specified multiple times to filter on more than one ID.

-
type
string
Enum: "Image" "iPXE"
type
string
Enum: "iPXE" "Templated iPXE" "Image"

Filter Operating Systems by Type

status
string

Filter Operating Systems by Status. Can be specified multiple times to filter on more than one status.

@@ -4106,7 +4106,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string
Enum: "iPXE" "Image"
type
string
Enum: "iPXE" "Templated iPXE" "Image"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from where the Operating System image can be retrieved

@@ -4122,8 +4122,16 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Root filesystem UUID, only applicable for image based Operating System

rootFsLabel
string or null

Root filesystem label, only applicable for image based Operating System

-
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System

+
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System. Mutually exclusive with ipxeTemplateId

+
ipxeTemplateId
string or null

Name of the iPXE template used by this Operating System. Mutually exclusive with ipxeScript

+
Array of objects (IpxeTemplateParameter)

Parameters passed to the iPXE template for variable substitution

+
Array of objects (IpxeTemplateArtifact)

Artifacts (kernel, initrd, etc.) for the iPXE OS definition

+
scope
string or null
Enum: "Local" "Global" "Limited"

Synchronization scope for iPXE Operating Systems; nil for Image OS. Local: single site, bidirectional sync (provider-owned, originating from carbide-core). Global: rest-to-core for all owner sites. Limited: rest-to-core for specific sites

userData
string or null

User data for the Operating System

isCloudInit
boolean
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
Example
[
  • {
    }
]

Create Operating System

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/operating-system

Response samples

Content type
application/json
Example
[
  • {
    }
]

Create Operating System

Create an Operating System for the org.

-

Either infrastructureProviderId or tenantId must be provided in request data. Both cannot be provided at the same time.

+

Either infrastructureProviderId or tenantId must be provided in request data. Both cannot be provided at the same time. For iPXE template-based OS definitions, Provider Admin may omit tenantId and have ownership resolved automatically.

If infrastructureProviderId is provided in request data, then org must have an Infrastructure Provider entity and its ID should match the query param value. User must have FORGE_PROVIDER_ADMIN role.

If tenantId is provided in request data, then org must have a Tenant entity and its ID should match the query param value. User must have FORGE_TENANT_ADMIN role.

-

Only Tenants are allowed to create Operating System for MVP.

+

Tenants can create iPXE script-based or Image-based Operating Systems. Provider Admins can create iPXE template-based Operating Systems (using ipxeTemplateId).

+

ipxeScript, ipxeTemplateId, and imageUrl are mutually exclusive — only one may be specified. When ipxeTemplateId is used, ipxeTemplateParameters and ipxeTemplateArtifacts can be provided to configure the template.

+

The scope field is required for Templated iPXE OSes and controls synchronization direction: Global (rest-to-core for all sites) or Limited (rest-to-core for sites in siteIds). Local scope cannot be specified at creation — Local Operating Systems originate in carbide-core and are synced to rest via inventory. Must not be specified for other OS types.

Authorizations:
JWTBearerToken
path Parameters
org
required
string

Name of the Org

Request Body schema: application/json
name
required
string [ 2 .. 256 ] characters
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Deprecated: Infrastructure Provider is now inferred from org membership.

tenantId
string or null <uuid>
Deprecated

Deprecated: Tenant is now inferred from org membership.

-
siteIds
Array of strings <uuid> [ items <uuid > ]

Specified only one Site if an Operating System is Image based, more than one Site is not supported"

-
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based OS. Cannot be specified if imageUrl is specified

+
siteIds
Array of strings <uuid> [ items <uuid > ]

For Image-based OS, specify exactly one Site. For limited-scope Templated iPXE OS, specify the target provider sites

+
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based OS. Mutually exclusive with ipxeTemplateId and imageUrl

+
ipxeTemplateId
string or null

Name of an iPXE template to use. Mutually exclusive with ipxeScript and imageUrl. ipxeTemplateParameters: type: array description: Parameters to pass to the iPXE template for variable substitution items: $ref:

+
Array of objects (IpxeTemplateArtifact)

Artifacts (kernel, initrd, etc.) required for the iPXE OS definition

+
scope
string or null
Enum: "Global" "Limited"

Synchronization scope. Required for Templated iPXE OS created by Provider Admin (when ipxeTemplateId is specified). For Raw iPXE OS, only "Global" or unspecified is accepted; the handler always normalizes raw iPXE to Global. Must not be set for Image OS (always nil).

imageUrl
string or null <uri>

Original URL from where the Operating System image can be retreived from, required for image based OS. Cannot be specified if ipxeScript is specified

imageSha
string or null
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string
Enum: "iPXE" "Image"
type
string
Enum: "iPXE" "Templated iPXE" "Image"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from where the Operating System image can be retrieved

@@ -4224,8 +4242,16 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Root filesystem UUID, only applicable for image based Operating System

rootFsLabel
string or null

Root filesystem label, only applicable for image based Operating System

-
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System

+
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System. Mutually exclusive with ipxeTemplateId

+
ipxeTemplateId
string or null

Name of the iPXE template used by this Operating System. Mutually exclusive with ipxeScript

+
Array of objects (IpxeTemplateParameter)

Parameters passed to the iPXE template for variable substitution

+
Array of objects (IpxeTemplateArtifact)

Artifacts (kernel, initrd, etc.) for the iPXE OS definition

+
scope
string or null
Enum: "Local" "Global" "Limited"

Synchronization scope for iPXE Operating Systems; nil for Image OS. Local: single site, bidirectional sync (provider-owned, originating from carbide-core). Global: rest-to-core for all owner sites. Limited: rest-to-core for specific sites

userData
string or null

User data for the Operating System

isCloudInit
boolean
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "name": "ubuntu-official-22.04",
  • "description": "Official Ubuntu 22.04",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\n shell",
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "isCloudInit": true,
  • "phoneHomeEnabled": true,
  • "allowOverride": false
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "ubuntu-22.04",
  • "description": "Ubuntu 22.04",
  • "infrastructureProviderId": null,
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "type": "iPXE",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\n shell",
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "isCloudInit": true,
  • "phoneHomeEnabled": false,
  • "allowOverride": false,
  • "imageAuthToken": null,
  • "imageAuthType": null,
  • "imageDisk": null,
  • "imageSha": null,
  • "imageUrl": null,
  • "rootFsId": null,
  • "rootFsLabel": null,
  • "siteAssociations": [ ],
  • "isActive": true,
  • "deactivationNote": null,
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve Operating System

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/operating-system

Request samples

Content type
application/json
Example
{
  • "name": "ubuntu-official-22.04",
  • "description": "Official Ubuntu 22.04",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\n shell",
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "isCloudInit": true,
  • "phoneHomeEnabled": true,
  • "allowOverride": false
}

Response samples

Content type
application/json
Example
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "ubuntu-22.04",
  • "description": "Ubuntu 22.04",
  • "infrastructureProviderId": null,
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "type": "iPXE",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\n shell",
  • "ipxeTemplateId": null,
  • "ipxeTemplateParameters": [ ],
  • "ipxeTemplateArtifacts": [ ],
  • "scope": "Global",
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "isCloudInit": true,
  • "phoneHomeEnabled": false,
  • "allowOverride": false,
  • "imageAuthToken": null,
  • "imageAuthType": null,
  • "imageDisk": null,
  • "imageSha": null,
  • "imageUrl": null,
  • "rootFsId": null,
  • "rootFsLabel": null,
  • "siteAssociations": [ ],
  • "isActive": true,
  • "deactivationNote": null,
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve Operating System

Get an Operating System by ID

@@ -4278,7 +4304,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string
Enum: "iPXE" "Image"
type
string
Enum: "iPXE" "Templated iPXE" "Image"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from where the Operating System image can be retrieved

@@ -4294,8 +4320,16 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Root filesystem UUID, only applicable for image based Operating System

rootFsLabel
string or null

Root filesystem label, only applicable for image based Operating System

-
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System

+
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System. Mutually exclusive with ipxeTemplateId

+
ipxeTemplateId
string or null

Name of the iPXE template used by this Operating System. Mutually exclusive with ipxeScript

+
Array of objects (IpxeTemplateParameter)

Parameters passed to the iPXE template for variable substitution

+
Array of objects (IpxeTemplateArtifact)

Artifacts (kernel, initrd, etc.) for the iPXE OS definition

+
scope
string or null
Enum: "Local" "Global" "Limited"

Synchronization scope for iPXE Operating Systems; nil for Image OS. Local: single site, bidirectional sync (provider-owned, originating from carbide-core). Global: rest-to-core for all owner sites. Limited: rest-to-core for specific sites

userData
string or null

User data for the Operating System

isCloudInit
boolean
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
Example
{
  • "id": "42b0f982-5c61-4d2f-a018-41ece61f4641",
  • "name": "debian-12-amd64",
  • "description": "Official Debian 12 for AMD/Intel",
  • "infrastructureProviderId": null,
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "type": "Image",
  • "imageSha": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
  • "imageAuthType": "Bearer",
  • "imageAuthToken": "acbd18db4cc2f85cedef654fccc4a4d8",
  • "imageDisk": "/dev/sda",
  • "rootFsId": "6c2ac315-3040-4728-94eb-b66d320206c1",
  • "rootFsLabel": null,
  • "ipxeScript": null,
  • "userData": null,
  • "isCloudInit": true,
  • "phoneHomeEnabled": false,
  • "allowOverride": false,
  • "siteAssociations": [
    ],
  • "isActive": true,
  • "deactivationNote": null,
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete Operating System

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/operating-system/{operatingSystemId}

Response samples

Content type
application/json
Example
{
  • "id": "42b0f982-5c61-4d2f-a018-41ece61f4641",
  • "name": "debian-12-amd64",
  • "description": "Official Debian 12 for AMD/Intel",
  • "infrastructureProviderId": null,
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "type": "Image",
  • "imageSha": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
  • "imageAuthType": "Bearer",
  • "imageAuthToken": "acbd18db4cc2f85cedef654fccc4a4d8",
  • "imageDisk": "/dev/sda",
  • "rootFsId": "6c2ac315-3040-4728-94eb-b66d320206c1",
  • "rootFsLabel": null,
  • "ipxeScript": null,
  • "ipxeTemplateId": null,
  • "ipxeTemplateParameters": [ ],
  • "ipxeTemplateArtifacts": [ ],
  • "scope": null,
  • "userData": null,
  • "isCloudInit": true,
  • "phoneHomeEnabled": false,
  • "allowOverride": false,
  • "siteAssociations": [
    ],
  • "isActive": true,
  • "deactivationNote": null,
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete Operating System

Delete an Operating System by ID

@@ -4341,9 +4375,11 @@

Typical API Call Flow for Tenant

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/operating-system/{operatingSystemId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update Operating System

Update an Operating System by ID

If the Operating System has infrastructureProviderId set, then org must have an Infrastructure Provider entity and its ID should match the Operating System Infrastructure Provider ID. User must have FORGE_PROVIDER_ADMIN authorization role. Provider must own the Operating System.

If the Operating System has tenantId set, then org must have a Tenant entity and its ID should match the Operating System Tenant ID. User must have FORGE_TENANT_ADMIN role. Tenant must own the Operating System.

+

The type and scope of an Operating System cannot be changed after creation.

Authorizations:
JWTBearerToken
path Parameters
org
required
string

Name of the Org

operatingSystemId
required
string
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Name of the Operating System

description
string or null

Optional description of the Operating System

-
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based OS. Cannot be specified if imageUrl is specified

+
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based OS. Mutually exclusive with ipxeTemplateId and imageUrl

+
ipxeTemplateId
string or null

Name of an iPXE template to use. Mutually exclusive with ipxeScript and imageUrl

+
Array of objects (IpxeTemplateParameter)

Parameters to pass to the iPXE template. If omitted the existing parameters are kept; if set to an empty array the parameters are cleared

+
Array of objects (IpxeTemplateArtifact)

Artifacts for the iPXE OS definition. If omitted the existing artifacts are kept; if set to an empty array the artifacts are cleared

imageUrl
string or null <uri>

Original URL from where the Operating System image can be retreived from, required for image based OS

imageSha
string or null
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string
Enum: "iPXE" "Image"
type
string
Enum: "iPXE" "Templated iPXE" "Image"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from where the Operating System image can be retrieved

@@ -4408,8 +4450,16 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Root filesystem UUID, only applicable for image based Operating System

rootFsLabel
string or null

Root filesystem label, only applicable for image based Operating System

-
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System

+
ipxeScript
string or null

iPXE script or URL, only applicable for iPXE based Operating System. Mutually exclusive with ipxeTemplateId

+
ipxeTemplateId
string or null

Name of the iPXE template used by this Operating System. Mutually exclusive with ipxeScript

+
Array of objects (IpxeTemplateParameter)

Parameters passed to the iPXE template for variable substitution

+
Array of objects (IpxeTemplateArtifact)

Artifacts (kernel, initrd, etc.) for the iPXE OS definition

+
scope
string or null
Enum: "Local" "Global" "Limited"

Synchronization scope for iPXE Operating Systems; nil for Image OS. Local: single site, bidirectional sync (provider-owned, originating from carbide-core). Global: rest-to-core for all owner sites. Limited: rest-to-core for specific sites

userData
string or null

User data for the Operating System

isCloudInit
boolean
Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "ubuntu-22.04-lts",
  • "description": "Ubuntu 22.04 LTS",
  • "allowOverride": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "ubuntu-22.04-lts",
  • "description": "Ubuntu 22.04 LTS",
  • "infrastructureProviderId": null,
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "type": "iPXE",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\n shell",
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "isCloudInit": true,
  • "allowOverride": true,
  • "phoneHomeEnabled": true,
  • "imageAuthToken": null,
  • "imageAuthType": null,
  • "imageDisk": null,
  • "imageSha": null,
  • "imageUrl": null,
  • "rootFsId": null,
  • "rootFsLabel": null,
  • "siteAssociations": [ ],
  • "isActive": true,
  • "deactivationNote": null,
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Instance Type

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/operating-system/{operatingSystemId}

Request samples

Content type
application/json
{
  • "name": "ubuntu-22.04-lts",
  • "description": "Ubuntu 22.04 LTS",
  • "allowOverride": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "ubuntu-22.04-lts",
  • "description": "Ubuntu 22.04 LTS",
  • "infrastructureProviderId": null,
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "type": "iPXE",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\n shell",
  • "ipxeTemplateId": null,
  • "ipxeTemplateParameters": [ ],
  • "ipxeTemplateArtifacts": [ ],
  • "scope": "Global",
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "isCloudInit": true,
  • "allowOverride": true,
  • "phoneHomeEnabled": true,
  • "imageAuthToken": null,
  • "imageAuthType": null,
  • "imageDisk": null,
  • "imageSha": null,
  • "imageUrl": null,
  • "rootFsId": null,
  • "rootFsLabel": null,
  • "siteAssociations": [ ],
  • "isActive": true,
  • "deactivationNote": null,
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

iPXE Template

iPXE Templates are boot script templates propagated from bare-metal-manager-core. They are read-only in the REST API.

+

Templates define the iPXE boot script structure with variable placeholders for parameters and artifact references. They can be used when creating iPXE template-based Operating System definitions.

+

Retrieve all iPXE Templates

Retrieve all iPXE templates propagated from bare-metal-manager-core. Templates are global — the id is the same UUID used by core across all sites — and are read-only from this service.

+

The siteId query parameter is optional and may be repeated to restrict results to templates that are currently available at one or more specific sites. When omitted, the caller receives every template available at any site they are authorized to see:

+
    +
  • Provider Admins/Viewers: templates available at any site owned by their infrastructure provider.
  • +
  • Tenant Admins: templates available at any site the tenant is associated with via a Tenant/Site association.
  • +
+

When siteId is provided, every requested site must be authorized for the caller.

+

Only Public-scoped templates are ever propagated from bare-metal-manager-core into this service, so no scope filter is offered.

+
Authorizations:
JWTBearerToken
path Parameters
org
required
string

Name of the Org

+
query Parameters
siteId
Array of strings <uuid> [ items <uuid > ]

Optional ID(s) of one or more Sites whose templates to retrieve. Repeat the parameter to request multiple sites. When omitted, templates are returned for every site the caller is authorized to see.

+
pageNumber
integer >= 1
Default: 1
Example: pageNumber=1

Page number for pagination query

+
pageSize
integer [ 1 .. 100 ]
Example: pageSize=20

Page size for pagination query

+
orderBy
string
Enum: "NAME_ASC" "NAME_DESC" "CREATED_ASC" "CREATED_DESC" "UPDATED_ASC" "UPDATED_DESC"

Ordering for pagination query

+

Responses

Response Headers
X-Pagination
string
Example: "{\"pageNumber\":1,\"pageSize\":20,\"total\":5,\"orderBy\": \"CREATED_DESC\"}"

Pagination result in JSON format

+
Response Schema: application/json
Array
id
string <uuid>

Stable template UUID assigned by core and identical across core and REST

+
name
string

Globally unique template name (e.g. ubuntu-autoinstall, kernel-initrd)

+
template
string

Raw iPXE script content with template variables

+
requiredParams
Array of strings

Parameters that must be provided to render the template

+
reservedParams
Array of strings

Parameters reserved by the template that cannot be user-supplied

+
requiredArtifacts
Array of strings

Artifact names (e.g. kernel, initrd) required for the template

+
scope
string
Enum: "Internal" "Public"

Visibility of this template: Internal or Public

+
created
string <date-time>

Date/time when the template was created

+
updated
string <date-time>

Date/time when the template was last updated

+

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve iPXE Template

Retrieve a global iPXE template by ID. The caller must be authorized at at least one site where this template is currently available (i.e. the caller must be the provider/owner or an authorized tenant at one of the sites the template has been reported from).

+

Provider Admins/Viewers can access templates for their provider's sites. Tenant Admins can access templates for sites the tenant is associated with via a Tenant/Site association.

+
Authorizations:
JWTBearerToken
path Parameters
org
required
string

Name of the Org

+
id
required
string <uuid>

ID of the iPXE Template

+

Responses

Response Schema: application/json
id
string <uuid>

Stable template UUID assigned by core and identical across core and REST

+
name
string

Globally unique template name (e.g. ubuntu-autoinstall, kernel-initrd)

+
template
string

Raw iPXE script content with template variables

+
requiredParams
Array of strings

Parameters that must be provided to render the template

+
reservedParams
Array of strings

Parameters reserved by the template that cannot be user-supplied

+
requiredArtifacts
Array of strings

Artifact names (e.g. kernel, initrd) required for the template

+
scope
string
Enum: "Internal" "Public"

Visibility of this template: Internal or Public

+
created
string <date-time>

Date/time when the template was created

+
updated
string <date-time>

Date/time when the template was last updated

+

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  • "name": "ubuntu-autoinstall",
  • "template": "#!ipxe\nkernel {{.kernel}} autoinstall ds=nocloud-net\ninitrd {{.initrd}}\nboot || imgfree\nshell",
  • "requiredParams": [
    ],
  • "reservedParams": [
    ],
  • "requiredArtifacts": [
    ],
  • "scope": "Public",
  • "created": "2025-10-15T10:30:00Z",
  • "updated": "2025-10-15T10:30:00Z"
}

Instance Type

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
Example
[
  • {
    }
]

Create an Instance Type

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type

Response samples

Content type
application/json
Example
[
  • {
    }
]

Create an Instance Type

Create an Instance Type for Infrastructure Provider.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -4516,7 +4658,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "name": "x3.large",
  • "description": "Part of X family, the X3 Large features increased compute power",
  • "siteId": "8d97fa69-9199-49ff-bcf3-168c62d3874e",
  • "labels": {
    },
  • "machineCapabilities": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "x3.large",
  • "description": "Part of X family, the X3 Large features increased compute power",
  • "infrastructureProviderId": "5f2cc306-76e9-4fca-9186-950c9ef9a74e",
  • "siteId": "72771e6a-6f5e-4de4-a5b9-1266c4197811",
  • "labels": {
    },
  • "machineCapabilities": [
    ],
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve an Instance Type

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type

Request samples

Content type
application/json
Example
{
  • "name": "x3.large",
  • "description": "Part of X family, the X3 Large features increased compute power",
  • "siteId": "8d97fa69-9199-49ff-bcf3-168c62d3874e",
  • "labels": {
    },
  • "machineCapabilities": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "x3.large",
  • "description": "Part of X family, the X3 Large features increased compute power",
  • "infrastructureProviderId": "5f2cc306-76e9-4fca-9186-950c9ef9a74e",
  • "siteId": "72771e6a-6f5e-4de4-a5b9-1266c4197811",
  • "labels": {
    },
  • "machineCapabilities": [
    ],
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve an Instance Type

Get an Instance Type by ID.

@@ -4544,7 +4686,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "x3.large",
  • "description": "Part of X family, the X3 Large features increased compute power",
  • "infrastructureProviderId": "5f2cc306-76e9-4fca-9186-950c9ef9a74e",
  • "siteId": "72771e6a-6f5e-4de4-a5b9-1266c4197811",
  • "labels": {
    },
  • "machineCapabilities": [
    ],
  • "allocationStats": {
    },
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete Instance Type

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type/{instanceTypeId}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "x3.large",
  • "description": "Part of X family, the X3 Large features increased compute power",
  • "infrastructureProviderId": "5f2cc306-76e9-4fca-9186-950c9ef9a74e",
  • "siteId": "72771e6a-6f5e-4de4-a5b9-1266c4197811",
  • "labels": {
    },
  • "machineCapabilities": [
    ],
  • "allocationStats": {
    },
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete Instance Type

Delete an Instance Type by ID.

Org must have an Infrastructure Provider entity that owns the Instance Type. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -4558,7 +4700,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update Instance Type

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type/{instanceTypeId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update Instance Type

Update an Instance Type by ID.

Org must have an Infrastructure Provider entity that owns the Instance Type. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -4578,7 +4720,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "description": "Updated version of the X3 Large family of machines",
  • "labels": {
    },
  • "machineCapabilities": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "x3.large",
  • "description": "Updated version of the X3 Large family of machines",
  • "infrastructureProviderId": "5f2cc306-76e9-4fca-9186-950c9ef9a74e",
  • "siteId": "72771e6a-6f5e-4de4-a5b9-1266c4197811",
  • "labels": {
    },
  • "machineCapabilities": [
    ],
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve all Machines/Instance Type associations

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type/{instanceTypeId}

Request samples

Content type
application/json
{
  • "description": "Updated version of the X3 Large family of machines",
  • "labels": {
    },
  • "machineCapabilities": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "x3.large",
  • "description": "Updated version of the X3 Large family of machines",
  • "infrastructureProviderId": "5f2cc306-76e9-4fca-9186-950c9ef9a74e",
  • "siteId": "72771e6a-6f5e-4de4-a5b9-1266c4197811",
  • "labels": {
    },
  • "machineCapabilities": [
    ],
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve all Machines/Instance Type associations

Get all Machines for a given Instance Type

Org must have an Infrastructure Provider entity that owns the Instance Type and the Machine. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -4602,7 +4744,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Create a Machine/Instance Type association

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type/{instanceTypeId}/machine

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Create a Machine/Instance Type association

Associate a Machine to an Instance Type

Org must have an Infrastructure Provider entity that owns the Instance Type and the Machine. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -4620,7 +4762,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "machineIds": [
    ]
}

Response samples

Content type
application/json
[
  • {
    },
  • {
    },
  • {
    }
]

Delete a Machine/Instance Type association

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type/{instanceTypeId}/machine

Request samples

Content type
application/json
{
  • "machineIds": [
    ]
}

Response samples

Content type
application/json
[
  • {
    },
  • {
    },
  • {
    }
]

Delete a Machine/Instance Type association

Delete a Machine's association with an Instance Type.

@@ -4638,7 +4780,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Instance

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/type/{instanceTypeId}/machine/{machineAssociationId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Instance

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
Example
[
  • {
    }
]

Create an Instance

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance

Response samples

Content type
application/json
Example
[
  • {
    }
]

Create an Instance

Create an Instance for Tenant.

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -4818,7 +4960,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "name": "spark-monitor-1",
  • "description": "Node for monitoring Spark",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\nshell",
  • "alwaysBootWithCustomIpxe": true,
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "labels": {
    },
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "sshKeyGroupIds": [
    ]
}

Response samples

Content type
application/json
Example
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "spark-monitor-1",
  • "description": "Node for monitoring Spark",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "machineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "controllerInstanceId": null,
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\nshell",
  • "alwaysBootWithCustomIpxe": true,
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "labels": {
    },
  • "isUpdatePending": false,
  • "serialConsoleUrl": "ssh://user@carbide.acme.com",
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [ ],
  • "sshKeyGroupIds": [
    ],
  • "sshKeyGroups": [
    ],
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Batch Create Instances

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance

Request samples

Content type
application/json
Example
{
  • "name": "spark-monitor-1",
  • "description": "Node for monitoring Spark",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\nshell",
  • "alwaysBootWithCustomIpxe": true,
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "labels": {
    },
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "sshKeyGroupIds": [
    ]
}

Response samples

Content type
application/json
Example
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "spark-monitor-1",
  • "description": "Node for monitoring Spark",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "machineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "controllerInstanceId": null,
  • "ipxeScript": "#!ipxe\nkernel http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/linux initrd=initrd.gz\ninitrd http://archive.ubuntu.com/ubuntu/dists/xenial/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/initrd.gz\nboot || imgfree\nshell",
  • "alwaysBootWithCustomIpxe": true,
  • "userData": "#cloud-config\nautoinstall:\n apt:\n geoip: true\n preserve_sources_list: false\n primary:\n - arches: [amd64, i386]\n uri: http://archive.ubuntu.com/ubuntu\n - arches: [default]\n uri: http://ports.ubuntu.com/ubuntu-ports",
  • "labels": {
    },
  • "isUpdatePending": false,
  • "serialConsoleUrl": "ssh://user@carbide.acme.com",
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [ ],
  • "sshKeyGroupIds": [
    ],
  • "sshKeyGroups": [
    ],
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Batch Create Instances

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "namePrefix": "gpu-worker",
  • "count": 4,
  • "description": "GPU worker nodes for distributed training",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "topologyOptimized": true,
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "sshKeyGroupIds": [
    ]
}

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Retrieve Instance

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/batch

Request samples

Content type
application/json
Example
{
  • "namePrefix": "gpu-worker",
  • "count": 4,
  • "description": "GPU worker nodes for distributed training",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "topologyOptimized": true,
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "sshKeyGroupIds": [
    ]
}

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Retrieve Instance

Get an Instance by ID

Org must have a Tenant entity. Instance must belong to Tenant. User must have FORGE_TENANT_ADMIN authorization role.

@@ -4966,7 +5108,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "spark-monitor-1",
  • "description": "Node for monitoring Spark",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "machineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "controllerInstanceId": null,
  • "ipxeScript": null,
  • "alwaysBootWithCustomIpxe": false,
  • "userData": null,
  • "networkSecurityGroupId": "c602eb90-3039-11f0-997a-b38d4fc8389e",
  • "networkSecurityGroupPropagationDetails": {
    },
  • "networkSecurityGroupInherited": false,
  • "labels": {
    },
  • "isUpdatePending": false,
  • "serialConsoleUrl": "ssh://user@carbide.acme.com",
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [
    ],
  • "sshKeyGroupIds": [
    ],
  • "sshKeyGroups": [
    ],
  • "tpmEkCertificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxVENDQWJ5Z0F3SUJBZ0lVTEE1ZHFPK1E5OXZQM3VYRTRKcjBncVRtOW93d0RRWUpLb1pJaHZjTkFRRUwKQlFBd0xqRUxNQWtHQTFVRUJoTUNWVk14RXpBUkJnTlZCQW9NQ2s1MmFXUnBZU0JEYjNKNw==",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete Instance

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/{instanceId}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "spark-monitor-1",
  • "description": "Node for monitoring Spark",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "machineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "controllerInstanceId": null,
  • "ipxeScript": null,
  • "alwaysBootWithCustomIpxe": false,
  • "userData": null,
  • "networkSecurityGroupId": "c602eb90-3039-11f0-997a-b38d4fc8389e",
  • "networkSecurityGroupPropagationDetails": {
    },
  • "networkSecurityGroupInherited": false,
  • "labels": {
    },
  • "isUpdatePending": false,
  • "serialConsoleUrl": "ssh://user@carbide.acme.com",
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [
    ],
  • "sshKeyGroupIds": [
    ],
  • "sshKeyGroups": [
    ],
  • "tpmEkCertificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxVENDQWJ5Z0F3SUJBZ0lVTEE1ZHFPK1E5OXZQM3VYRTRKcjBncVRtOW93d0RRWUpLb1pJaHZjTkFRRUwKQlFBd0xqRUxNQWtHQTFVRUJoTUNWVk14RXpBUkJnTlZCQW9NQ2s1MmFXUnBZU0JEYjNKNw==",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete Instance

Delete an Instance by ID

Org must have a Tenant entity. Instance must belong to Tenant. User must have FORGE_TENANT_ADMIN authorization role.

@@ -4984,7 +5126,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Accepted

Request samples

Content type
application/json
{
  • "machineHealthIssue": {
    },
  • "isRepairTenant": false
}

Update Instance

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/{instanceId}

Request samples

Content type
application/json
{
  • "machineHealthIssue": {
    },
  • "isRepairTenant": false
}

Update Instance

Update an Instance by ID

Org must have a Tenant entity. Instance must belong to Tenant. User must have FORGE_TENANT_ADMIN authorization role.

@@ -5070,7 +5212,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "spark-monitor-1",
  • "description": "Spark Monitor Node 1",
  • "triggerReboot": true,
  • "rebootWithCustomIpxe": true,
  • "applyUpdatesOnReboot": true,
  • "sshKeyGroupIds": [
    ],
  • "labels": {
    },
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "spark-monitor-2",
  • "description": "Spark Monitor Node 1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "machineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "controllerInstanceId": "158fc2bc-f2fb-4e1f-a5a4-2211062d14df",
  • "ipxeScript": null,
  • "alwaysBootWithCustomIpxe": false,
  • "userData": null,
  • "labels": {
    },
  • "isUpdatePending": false,
  • "serialConsoleUrl": "ssh://user@carbide.acme.com",
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [
    ],
  • "sshKeyGroupIds": [
    ],
  • "sshKeyGroups": [
    ],
  • "tpmEkCertificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxVENDQWJ5Z0F3SUJBZ0lVTEE1ZHFPK1E5OXZQM3VYRTRKcjBncVRtOW93d0RRWUpLb1pJaHZjTkFRRUwKQlFBd0xqRUxNQWtHQTFVRUJoTUNWVk14RXpBUkJnTlZCQW9NQ2s1MmFXUnBZU0JEYjNKNw==",
  • "status": "Rebooting",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve Instance status history

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/{instanceId}

Request samples

Content type
application/json
{
  • "name": "spark-monitor-1",
  • "description": "Spark Monitor Node 1",
  • "triggerReboot": true,
  • "rebootWithCustomIpxe": true,
  • "applyUpdatesOnReboot": true,
  • "sshKeyGroupIds": [
    ],
  • "labels": {
    },
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "nvLinkInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "spark-monitor-2",
  • "description": "Spark Monitor Node 1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "41e36058-8403-4086-a9b8-39cb5bc9cb98",
  • "vpcId": "5e28ad7c-5fb7-46d6-a28a-fc0ba6fdc4a3",
  • "machineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "operatingSystemId": "eaeb86ee-c435-444e-9e01-8346f67f194b",
  • "controllerInstanceId": "158fc2bc-f2fb-4e1f-a5a4-2211062d14df",
  • "ipxeScript": null,
  • "alwaysBootWithCustomIpxe": false,
  • "userData": null,
  • "labels": {
    },
  • "isUpdatePending": false,
  • "serialConsoleUrl": "ssh://user@carbide.acme.com",
  • "interfaces": [
    ],
  • "infinibandInterfaces": [
    ],
  • "dpuExtensionServiceDeployments": [
    ],
  • "sshKeyGroupIds": [
    ],
  • "sshKeyGroups": [
    ],
  • "tpmEkCertificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxVENDQWJ5Z0F3SUJBZ0lVTEE1ZHFPK1E5OXZQM3VYRTRKcjBncVRtOW93d0RRWUpLb1pJaHZjTkFRRUwKQlFBd0xqRUxNQWtHQTFVRUJoTUNWVk14RXpBUkJnTlZCQW9NQ2s1MmFXUnBZU0JEYjNKNw==",
  • "status": "Rebooting",
  • "statusHistory": [
    ],
  • "deprecations": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve Instance status history

Get Instance status history

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -5092,7 +5234,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve all Interfaces

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/{instanceId}/status-history

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve all Interfaces

Get all Interfaces for an Instance

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -5130,7 +5272,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Retrieve all Instance InfiniBand Interfaces

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/{instanceId}/interface

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Retrieve all Instance InfiniBand Interfaces

Get all InfiniBand Interfaces for an Instance

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -5164,7 +5306,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Machine

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/instance/{instanceId}/nvlink-interface

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Machine

Machine is a physical server that contains CPUs, GPUs, memory, storage, and networking hardware. Machines are the physical building blocks of a Site.

Retrieve all Machines

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve a Machine

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve a Machine

Org must have either an Infrastructure Provider entity or a Tenant entity.

@@ -5338,7 +5480,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
Example
{
  • "id": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "2e016c02-2c67-48aa-b289-5d3ca6320c52",
  • "instanceId": "59bdaaff-3998-4fd9-a140-8749beeb605e",
  • "tenantId": "99819e6e-4017-4021-9edd-ea1bdf4dbd59",
  • "controllerMachineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "controllerMachineType": "x86_64",
  • "hwSkuDeviceType": "cpu",
  • "vendor": "Lenovo",
  • "productName": "ThinkSystem SR670 V2",
  • "serialNumber": "J1060ACR.D3KS2CS001G",
  • "machineCapabilities": [
    ],
  • "machineInterfaces": [
    ],
  • "maintenanceMessage": null,
  • "health": {
    },
  • "labels": {
    },
  • "status": "Ready",
  • "isUsableByTenant": true,
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Update Machine

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine/{machineId}

Response samples

Content type
application/json
Example
{
  • "id": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "2e016c02-2c67-48aa-b289-5d3ca6320c52",
  • "instanceId": "59bdaaff-3998-4fd9-a140-8749beeb605e",
  • "tenantId": "99819e6e-4017-4021-9edd-ea1bdf4dbd59",
  • "controllerMachineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "controllerMachineType": "x86_64",
  • "hwSkuDeviceType": "cpu",
  • "vendor": "Lenovo",
  • "productName": "ThinkSystem SR670 V2",
  • "serialNumber": "J1060ACR.D3KS2CS001G",
  • "machineCapabilities": [
    ],
  • "machineInterfaces": [
    ],
  • "maintenanceMessage": null,
  • "health": {
    },
  • "labels": {
    },
  • "status": "Ready",
  • "isUsableByTenant": true,
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Update Machine

Update a Machine

@@ -5396,7 +5538,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Indicates whether the machine is usable by or currently in use by a tenant.

Array of objects (StatusDetail)
created
string <date-time>
updated
string <date-time>

Request samples

Content type
application/json
Example
{
  • "instanceTypeId": "2e016c02-2c67-48aa-b289-5d3ca6320c52"
}

Response samples

Content type
application/json
{
  • "id": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "2e016c02-2c67-48aa-b289-5d3ca6320c52",
  • "instanceId": "59bdaaff-3998-4fd9-a140-8749beeb605e",
  • "tenantId": "99819e6e-4017-4021-9edd-ea1bdf4dbd59",
  • "controllerMachineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "controllerMachineType": "x86_64",
  • "hwSkuDeviceType": "cpu",
  • "vendor": "Lenovo",
  • "productName": "ThinkSystem SR670 V2",
  • "serialNumber": "J1060ACR.D3KS2CS001G",
  • "machineCapabilities": [
    ],
  • "machineInterfaces": [
    ],
  • "maintenanceMessage": null,
  • "health": {
    },
  • "labels": {
    },
  • "status": "Ready",
  • "isUsableByTenant": true,
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete a Machine from a Site

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine/{machineId}

Request samples

Content type
application/json
Example
{
  • "instanceTypeId": "2e016c02-2c67-48aa-b289-5d3ca6320c52"
}

Response samples

Content type
application/json
{
  • "id": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "instanceTypeId": "2e016c02-2c67-48aa-b289-5d3ca6320c52",
  • "instanceId": "59bdaaff-3998-4fd9-a140-8749beeb605e",
  • "tenantId": "99819e6e-4017-4021-9edd-ea1bdf4dbd59",
  • "controllerMachineId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "controllerMachineType": "x86_64",
  • "hwSkuDeviceType": "cpu",
  • "vendor": "Lenovo",
  • "productName": "ThinkSystem SR670 V2",
  • "serialNumber": "J1060ACR.D3KS2CS001G",
  • "machineCapabilities": [
    ],
  • "machineInterfaces": [
    ],
  • "maintenanceMessage": null,
  • "health": {
    },
  • "labels": {
    },
  • "status": "Ready",
  • "isUsableByTenant": true,
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete a Machine from a Site

Org must have an Infrastructure Provider entity. Machine must belong to the Provider. User must have FORGE_PROVIDER_ADMIN authorization role. Machine must meet certain criteria to be eligible for deletion.

Authorizations:
JWTBearerToken
path Parameters
org
required
string

Name of the Org

@@ -5416,7 +5558,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Describes an error response for 500 Internal Server Error

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "Error validating request data",
  • "data": {
    }
}

Retrieve Machine status history

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine/{machineId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "Error validating request data",
  • "data": {
    }
}

Retrieve Machine status history

Org must have either an Infrastructure Provider entity or a Tenant entity.

@@ -5440,7 +5582,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve GPU stats for machines at a site

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine/{machineId}/status-history

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve GPU stats for machines at a site

Returns GPU summary stats grouped by GPU name for machines at the specified site.

User must have FORGE_PROVIDER_ADMIN authorization role. The specified site must belong to the Provider.

@@ -5460,7 +5602,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve machine instance type assignment summary for a site

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine/gpu/stats

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve machine instance type assignment summary for a site

Returns machine counts grouped by assigned (has instance type) vs unassigned, broken down by status.

User must have FORGE_PROVIDER_ADMIN authorization role. The specified site must belong to the Provider.

@@ -5478,7 +5620,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "assigned": {
    },
  • "unassigned": {
    }
}

Retrieve detailed per-instance-type machine stats for a site

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine/instance-type/stats/summary

Response samples

Content type
application/json
{
  • "assigned": {
    },
  • "unassigned": {
    }
}

Retrieve detailed per-instance-type machine stats for a site

Returns machine stats for each instance type including allocation details and tenant breakdown.

User must have FORGE_PROVIDER_ADMIN authorization role. The specified site must belong to the Provider.

@@ -5504,7 +5646,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve all Machine Capabilities

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine/instance-type/stats

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve all Machine Capabilities

Get all distinct Machine Capabilities across all Machines

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -5562,7 +5704,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    },
  • {
    },
  • {
    },
  • {
    },
  • {
    },
  • {
    }
]

Machine Capability

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/machine-capability

Response samples

Content type
application/json
[
  • {
    },
  • {
    },
  • {
    },
  • {
    },
  • {
    },
  • {
    }
]

Machine Capability

Machine Capability defines the hardware capabilities of a Machine. Machine Capabilities can be used to group Machines into Instance Types.

Rack

Rack is a physical enclosure that contains a number of Machines. Racks are the physical building blocks of a Site.

@@ -5610,7 +5752,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve a Rack

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve a Rack

Get a Rack by ID.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -5646,7 +5788,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when requested object is not found

Response samples

Content type
application/json
{
  • "id": "550e8400-e29b-41d4-a716-446655440000",
  • "name": "Rack-01",
  • "manufacturer": "Dell",
  • "model": "PowerEdge R750",
  • "serialNumber": "SN-RACK-001",
  • "description": "Primary compute rack",
  • "location": {
    },
  • "components": [
    ]
}

Validate Racks

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/{id}

Response samples

Content type
application/json
{
  • "id": "550e8400-e29b-41d4-a716-446655440000",
  • "name": "Rack-01",
  • "manufacturer": "Dell",
  • "model": "PowerEdge R750",
  • "serialNumber": "SN-RACK-001",
  • "description": "Primary compute rack",
  • "location": {
    },
  • "components": [
    ]
}

Validate Racks

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
Example
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 10
}

Validate a Rack

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/validation

Response samples

Content type
application/json
Example
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 10
}

Validate a Rack

Validate a Rack's components by comparing expected vs actual state.

@@ -5714,7 +5856,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
Example
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 5
}

Power control Racks

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/{id}/validation

Response samples

Content type
application/json
Example
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 5
}

Power control Racks

Power control Racks with optional filters. If no filter is specified, targets all racks in the Site.

@@ -5738,7 +5880,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "off"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Power control a Rack

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/power

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "off"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Power control a Rack

Power control a Rack identified by Rack UUID.

@@ -5776,7 +5918,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "on"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update Racks

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/{id}/power

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "on"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update Racks

Update firmware on Racks with optional name filter. If no filter is specified, targets all racks in the Site.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -5798,7 +5940,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update a Rack

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/firmware

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update a Rack

Update firmware on a Rack identified by Rack UUID.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -5820,7 +5962,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "version": "24.11.0"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Bring up Racks

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/{id}/firmware

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "version": "24.11.0"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Bring up Racks

Bring up Racks with optional name filter. If no filter is specified, targets all racks in the Site.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -5842,7 +5984,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Bring up a Rack

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/bringup

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Bring up a Rack

Bring up a Rack identified by Rack UUID.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -5864,7 +6006,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Retrieve a Task

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/{id}/bringup

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Retrieve a Task

Get a Task by UUID.

@@ -5902,7 +6044,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when requested object is not found

Response samples

Content type
application/json
{
  • "id": "550e8400-e29b-41d4-a716-446655440000",
  • "status": "Running",
  • "description": "Power on rack components",
  • "message": "Processing 3 of 5 components"
}

Tray

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/rack/task/{id}

Response samples

Content type
application/json
{
  • "id": "550e8400-e29b-41d4-a716-446655440000",
  • "status": "Running",
  • "description": "Power on rack components",
  • "message": "Processing 3 of 5 components"
}

Tray

Tray represents a component within a Rack.

Retrieve all Trays

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve a Tray

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve a Tray

Get a Tray by ID.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -6018,7 +6160,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when requested object is not found

Response samples

Content type
application/json
{
  • "id": "660e8400-e29b-41d4-a716-446655440001",
  • "componentId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "type": "compute",
  • "name": "compute-tray-1",
  • "manufacturer": "NVIDIA",
  • "model": "GB200",
  • "serialNumber": "TSN001",
  • "description": "Compute tray in slot 1",
  • "firmwareVersion": "2.1.0",
  • "powerState": "on",
  • "position": {
    },
  • "rackId": "550e8400-e29b-41d4-a716-446655440000"
}

Validate Trays

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray/{id}

Response samples

Content type
application/json
{
  • "id": "660e8400-e29b-41d4-a716-446655440001",
  • "componentId": "fm100ht4v4mce2qstjnl8970nnj3ie6ecek4mtjn27pea4kre5gsa49jg0g",
  • "type": "compute",
  • "name": "compute-tray-1",
  • "manufacturer": "NVIDIA",
  • "model": "GB200",
  • "serialNumber": "TSN001",
  • "description": "Compute tray in slot 1",
  • "firmwareVersion": "2.1.0",
  • "powerState": "on",
  • "position": {
    },
  • "rackId": "550e8400-e29b-41d4-a716-446655440000"
}

Validate Trays

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 10
}

Validate a Tray

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray/validation

Response samples

Content type
application/json
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 10
}

Validate a Tray

Validate a Tray by comparing expected vs actual state.

@@ -6094,7 +6236,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 5
}

Power control Trays

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray/{id}/validation

Response samples

Content type
application/json
{
  • "diffs": [ ],
  • "totalDiffs": 0,
  • "missingCount": 0,
  • "unexpectedCount": 0,
  • "driftCount": 0,
  • "matchCount": 5
}

Power control Trays

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "on"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Power control a Tray

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray/power

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "on"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Power control a Tray

Power control a Tray identified by Tray UUID.

@@ -6170,7 +6312,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "on"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update Trays

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray/{id}/power

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "state": "on"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update Trays

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update a Tray

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray/firmware

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Firmware update a Tray

Update firmware on a Tray identified by Tray UUID.

Org must have an Infrastructure Provider entity. User must have FORGE_PROVIDER_ADMIN authorization role.

@@ -6228,7 +6370,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "version": "24.11.0"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Network Security Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/tray/{id}/firmware

Request samples

Content type
application/json
Example
{
  • "siteId": "550e8400-e29b-41d4-a716-446655440000",
  • "version": "24.11.0"
}

Response samples

Content type
application/json
{
  • "taskIds": [
    ]
}

Network Security Group

Network Security Group is a security policy that controls the traffic flowing between Instances.

Retrieve all Network Security Groups

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Describes an error response for 501 Not Implemented

Response samples

Content type
application/json
[
  • {
    }
]

Create Network Security Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/network-security-group

Response samples

Content type
application/json
[
  • {
    }
]

Create Network Security Group

Create a Network Security Group for Tenant.

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -6296,7 +6438,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Describes an error response for 501 Not Implemented

Request samples

Content type
application/json
{
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "188a8f32-0001-45cf-b243-f62720a22cc4",
  • "rules": [
    ],
  • "labels": {
    }
}

Response samples

Content type
application/json
{
  • "id": "2a21cf79-ea5e-4d28-b585-2e78948fcefb",
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "56f1a3ed-3653-454f-b861-9136207be660",
  • "tenantId": "79595ebe-934f-4f19-bc74-c16aefd0c57a",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "rules": [
    ],
  • "labels": {
    },
  • "created": "2025-02-26T18:17:44.861317-05:00",
  • "updated": "2025-02-26T18:17:44.861317-05:00"
}

Retrieve Network Security Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/network-security-group

Request samples

Content type
application/json
{
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "188a8f32-0001-45cf-b243-f62720a22cc4",
  • "rules": [
    ],
  • "labels": {
    }
}

Response samples

Content type
application/json
{
  • "id": "2a21cf79-ea5e-4d28-b585-2e78948fcefb",
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "56f1a3ed-3653-454f-b861-9136207be660",
  • "tenantId": "79595ebe-934f-4f19-bc74-c16aefd0c57a",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "rules": [
    ],
  • "labels": {
    },
  • "created": "2025-02-26T18:17:44.861317-05:00",
  • "updated": "2025-02-26T18:17:44.861317-05:00"
}

Retrieve Network Security Group

Get a Network Security Group by ID

Org must have a Tenant entity. Instance must belong to Tenant. User must have FORGE_TENANT_ADMIN authorization role.

@@ -6322,7 +6464,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Describes an error response for 501 Not Implemented

Response samples

Content type
application/json
{
  • "id": "2a21cf79-ea5e-4d28-b585-2e78948fcefb",
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "56f1a3ed-3653-454f-b861-9136207be660",
  • "tenantId": "79595ebe-934f-4f19-bc74-c16aefd0c57a",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "rules": [
    ],
  • "labels": {
    },
  • "created": "2025-02-26T18:17:44.861317-05:00",
  • "updated": "2025-02-26T18:17:44.861317-05:00"
}

Update Network Security Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/network-security-group/{networkSecurityGroupId}

Response samples

Content type
application/json
{
  • "id": "2a21cf79-ea5e-4d28-b585-2e78948fcefb",
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "56f1a3ed-3653-454f-b861-9136207be660",
  • "tenantId": "79595ebe-934f-4f19-bc74-c16aefd0c57a",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "rules": [
    ],
  • "labels": {
    },
  • "created": "2025-02-26T18:17:44.861317-05:00",
  • "updated": "2025-02-26T18:17:44.861317-05:00"
}

Update Network Security Group

Update a Network Security Group by ID

@@ -6352,7 +6494,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Describes an error response for 501 Not Implemented

Request samples

Content type
application/json
{
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "rules": [
    ],
  • "labels": {
    }
}

Response samples

Content type
application/json
{
  • "id": "2a21cf79-ea5e-4d28-b585-2e78948fcefb",
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "56f1a3ed-3653-454f-b861-9136207be660",
  • "tenantId": "79595ebe-934f-4f19-bc74-c16aefd0c57a",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "rules": [
    ],
  • "labels": {
    },
  • "created": "2025-02-26T18:17:44.861317-05:00",
  • "updated": "2025-02-26T18:17:44.861317-05:00"
}

Delete Network Security Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/network-security-group/{networkSecurityGroupId}

Request samples

Content type
application/json
{
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "rules": [
    ],
  • "labels": {
    }
}

Response samples

Content type
application/json
{
  • "id": "2a21cf79-ea5e-4d28-b585-2e78948fcefb",
  • "name": "Spark VPC Firewall",
  • "description": "Security policies for machines in Spark VPC",
  • "siteId": "56f1a3ed-3653-454f-b861-9136207be660",
  • "tenantId": "79595ebe-934f-4f19-bc74-c16aefd0c57a",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "rules": [
    ],
  • "labels": {
    },
  • "created": "2025-02-26T18:17:44.861317-05:00",
  • "updated": "2025-02-26T18:17:44.861317-05:00"
}

Delete Network Security Group

Delete a Network Security Group by ID

@@ -6378,7 +6520,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Describes an error response for 501 Not Implemented

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "Error validating request data",
  • "data": {
    }
}

IP Block

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/network-security-group/{networkSecurityGroupId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "Error validating request data",
  • "data": {
    }
}

IP Block

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Create IP Block

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/ipblock

Response samples

Content type
application/json
[
  • {
    }
]

Create IP Block

Create an IP block for the org.

@@ -6460,7 +6602,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "Public Network Overlay for Site SJC4",
  • "description": "This is the primary IP overlay for SJC4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "routingType": "Public",
  • "prefix": "202.168.1.0",
  • "prefixLength": 24,
  • "protocolVersion": "IPv4"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "Public Network Overlay for Site SJC4",
  • "description": "This is the primary IP overlay for SJC4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "tenantId": null,
  • "routingType": "Public",
  • "prefix": "202.168.1.0",
  • "prefixLength": 24,
  • "protocolVersion": "IPv4",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve IP Block

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/ipblock

Request samples

Content type
application/json
{
  • "name": "Public Network Overlay for Site SJC4",
  • "description": "This is the primary IP overlay for SJC4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "routingType": "Public",
  • "prefix": "202.168.1.0",
  • "prefixLength": 24,
  • "protocolVersion": "IPv4"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "Public Network Overlay for Site SJC4",
  • "description": "This is the primary IP overlay for SJC4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "tenantId": null,
  • "routingType": "Public",
  • "prefix": "202.168.1.0",
  • "prefixLength": 24,
  • "protocolVersion": "IPv4",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve IP Block

Retrieve an IP Block by ID.

User must have FORGE_PROVIDER_ADMIN or FORGE_TENANT_ADMIN role.

@@ -6490,7 +6632,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "Public Network Overlay for Site SJC4",
  • "description": "This is the primary IP overlay for SJC4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "tenantId": null,
  • "routingType": "Public",
  • "prefix": "202.168.16.0",
  • "prefixLength": 20,
  • "protocolVersion": "IPv4",
  • "usageStats": {
    },
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete IP Block

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/ipblock/{ipBlockId}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "Public Network Overlay for Site SJC4",
  • "description": "This is the primary IP overlay for SJC4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "tenantId": null,
  • "routingType": "Public",
  • "prefix": "202.168.16.0",
  • "prefixLength": 20,
  • "protocolVersion": "IPv4",
  • "usageStats": {
    },
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete IP Block

Delete an IP block

@@ -6508,7 +6650,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "Error validating request data",
  • "data": {
    }
}

Update IP Block

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/ipblock/{ipBlockId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "Error validating request data",
  • "data": {
    }
}

Update IP Block

Update an existing IP Block

@@ -6530,7 +6672,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Status values for IP Block objects

Array of objects (StatusDetail)
Array of objects (Deprecation)
created
string <date-time>
updated
string <date-time>

Request samples

Content type
application/json
{
  • "name": "Public Network Overlay for Site SJC-4",
  • "description": "This is the primary IP overlay for SJC-4. All IPs are publicly routable"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "Public Network Overlay for Site SJC-4",
  • "description": "This is the primary IP overlay for SJC-4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "tenantId": null,
  • "routingType": "Public",
  • "prefix": "202.168.16.0",
  • "prefixLength": 20,
  • "protocolVersion": "IPv4",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve All Derived IP Blocks

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/ipblock/{ipBlockId}

Request samples

Content type
application/json
{
  • "name": "Public Network Overlay for Site SJC-4",
  • "description": "This is the primary IP overlay for SJC-4. All IPs are publicly routable"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "Public Network Overlay for Site SJC-4",
  • "description": "This is the primary IP overlay for SJC-4. All IPs are publicly routable",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "infrastructureProviderId": "e94bcfda-f6cb-42e4-80ec-516811e5abbf",
  • "tenantId": null,
  • "routingType": "Public",
  • "prefix": "202.168.16.0",
  • "prefixLength": 20,
  • "protocolVersion": "IPv4",
  • "status": "Pending",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve All Derived IP Blocks

Retrieve all child IP Blocks allocated to Tenants from a specific Provider super IP Block. When allocations are created from a super block, individual Tenant IP Blocks are created as a result.

@@ -6568,7 +6710,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

DPU Extension Service

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/ipblock/{ipBlockId}/derived

Response samples

Content type
application/json
[
  • {
    }
]

DPU Extension Service

DPU Extension Service allows users to run custom services in the DPUs of their Instances. Currently K8s pods are the only supported service type.

Retrieve all DPU Extension Services

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Create DPU Extension Service

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/dpu-extension-service

Response samples

Content type
application/json
[
  • {
    }
]

Create DPU Extension Service

Create a DPU Extension Service for the current Tenant.

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -6676,7 +6818,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "data": "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: busybox-deployment\\n labels:\\n app: busybox\\nspec:\\n replicas: 1 # You can adjust the number of desired replicas here\\n selector:\\n matchLabels:\\n app: busybox\\n template:\\n metadata:\\n labels:\\n app: busybox\\n spec:\\n containers:\\n - name: busybox-container\\n image: busybox:latest # You can specify a different BusyBox image tag\\n command: [\"sh\", \"-c\", \"echo \\'BusyBox container running\\' && sleep 3600\"]",
  • "credentials": {},
  • "observability": {
    }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "V1-T1761856992374052",
  • "versionInfo": {
    },
  • "activeVersions": [
    ],
  • "status": "Ready",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve DPU Extension Service

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/dpu-extension-service

Request samples

Content type
application/json
{
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "data": "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: busybox-deployment\\n labels:\\n app: busybox\\nspec:\\n replicas: 1 # You can adjust the number of desired replicas here\\n selector:\\n matchLabels:\\n app: busybox\\n template:\\n metadata:\\n labels:\\n app: busybox\\n spec:\\n containers:\\n - name: busybox-container\\n image: busybox:latest # You can specify a different BusyBox image tag\\n command: [\"sh\", \"-c\", \"echo \\'BusyBox container running\\' && sleep 3600\"]",
  • "credentials": {},
  • "observability": {
    }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "V1-T1761856992374052",
  • "versionInfo": {
    },
  • "activeVersions": [
    ],
  • "status": "Ready",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve DPU Extension Service

Retrieve a DPU Extension Service for the current Tenant by ID

@@ -6716,7 +6858,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "V1-T1761856992374052",
  • "versionInfo": {
    },
  • "activeVersions": [
    ],
  • "status": "Ready",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete DPU Extension Service

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/dpu-extension-service/{dpuExtensionServiceId}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "V1-T1761856992374052",
  • "versionInfo": {
    },
  • "activeVersions": [
    ],
  • "status": "Ready",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete DPU Extension Service

Delete a specific DPU Extension Service by ID. All versions will be deleted.

@@ -6732,7 +6874,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update DPU Extension Service

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/dpu-extension-service/{dpuExtensionServiceId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update DPU Extension Service

Update a specific DPU Extension Service.

@@ -6788,7 +6930,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when requested object is not found

Request samples

Content type
application/json
{
  • "name": "busybox-ha",
  • "data": "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: busybox-deployment\\n labels:\\n app: busybox\\nspec:\\n replicas: 3 # You can adjust the number of desired replicas here\\n selector:\\n matchLabels:\\n app: busybox\\n template:\\n metadata:\\n labels:\\n app: busybox\\n spec:\\n containers:\\n - name: busybox-container\\n image: busybox:latest # You can specify a different BusyBox image tag\\n command: [\"sh\", \"-c\", \"echo \\'BusyBox container running\\' && sleep 3600\"]",
  • "credentials": {},
  • "observability": {
    }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "V1-T1761856992374052",
  • "versionInfo": {
    },
  • "activeVersions": [
    ],
  • "status": "Ready",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve DPU Extension Service Version

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/dpu-extension-service/{dpuExtensionServiceId}

Request samples

Content type
application/json
{
  • "name": "busybox-ha",
  • "data": "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: busybox-deployment\\n labels:\\n app: busybox\\nspec:\\n replicas: 3 # You can adjust the number of desired replicas here\\n selector:\\n matchLabels:\\n app: busybox\\n template:\\n metadata:\\n labels:\\n app: busybox\\n spec:\\n containers:\\n - name: busybox-container\\n image: busybox:latest # You can specify a different BusyBox image tag\\n command: [\"sh\", \"-c\", \"echo \\'BusyBox container running\\' && sleep 3600\"]",
  • "credentials": {},
  • "observability": {
    }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "busybox",
  • "description": "Single, multi-call executable that contains stripped-down versions of common Unix utilities",
  • "serviceType": "KubernetesPod",
  • "siteId": "60189e9c-7d12-438c-b9ca-6998d9c364b1",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "V1-T1761856992374052",
  • "versionInfo": {
    },
  • "activeVersions": [
    ],
  • "status": "Ready",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve DPU Extension Service Version

Retrieve details for a specific version of a DPU Extension Service.

@@ -6816,7 +6958,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when requested object is not found

Response samples

Content type
application/json
{
  • "version": "V1-T1761856992374052",
  • "data": "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: busybox-deployment\\n labels:\\n app: busybox\\nspec:\\n replicas: 1 # You can adjust the number of desired replicas here\\n selector:\\n matchLabels:\\n app: busybox\\n template:\\n metadata:\\n labels:\\n app: busybox\\n spec:\\n containers:\\n - name: busybox-container\\n image: busybox:latest # You can specify a different BusyBox image tag\\n command: [\"sh\", \"-c\", \"echo \\'BusyBox container running\\' && sleep 3600\"]",
  • "hasCredentials": true,
  • "created": "2019-08-24T14:15:22Z",
  • "observability": {
    }
}

Delete DPU Extension Service Version

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/dpu-extension-service/{dpuExtensionServiceId}/version/{version}

Response samples

Content type
application/json
{
  • "version": "V1-T1761856992374052",
  • "data": "apiVersion: apps/v1\\nkind: Deployment\\nmetadata:\\n name: busybox-deployment\\n labels:\\n app: busybox\\nspec:\\n replicas: 1 # You can adjust the number of desired replicas here\\n selector:\\n matchLabels:\\n app: busybox\\n template:\\n metadata:\\n labels:\\n app: busybox\\n spec:\\n containers:\\n - name: busybox-container\\n image: busybox:latest # You can specify a different BusyBox image tag\\n command: [\"sh\", \"-c\", \"echo \\'BusyBox container running\\' && sleep 3600\"]",
  • "hasCredentials": true,
  • "created": "2019-08-24T14:15:22Z",
  • "observability": {
    }
}

Delete DPU Extension Service Version

Delete a specific version of a DPU Extension Service.

@@ -6834,7 +6976,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

SSH Key Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/dpu-extension-service/{dpuExtensionServiceId}/version/{version}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

SSH Key Group

SSH Key Groups allow grouping several SSH Keys together so they can be synced to Sites and used to access the Serial Console of Instances.

Retrieve all SSH Key Groups

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Create SSH Key Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkeygroup

Response samples

Content type
application/json
[
  • {
    }
]

Create SSH Key Group

Create an SSH Key Group for the current Tenant.

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -6924,7 +7066,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "reno-integration-sre",
  • "description": "SRE access SSH keys for Reno Integration",
  • "siteIds": [
    ],
  • "sshKeyIds": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-integration-sre",
  • "description": "SRE access SSH keys for Reno Integration",
  • "org": "wdksahew1rqf",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75",
  • "siteAssociations": [
    ],
  • "sshKeys": [
    ],
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve an SSH Key Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkeygroup

Request samples

Content type
application/json
{
  • "name": "reno-integration-sre",
  • "description": "SRE access SSH keys for Reno Integration",
  • "siteIds": [
    ],
  • "sshKeyIds": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-integration-sre",
  • "description": "SRE access SSH keys for Reno Integration",
  • "org": "wdksahew1rqf",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75",
  • "siteAssociations": [
    ],
  • "sshKeys": [
    ],
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve an SSH Key Group

Retrieve an SSH Key Group for the current Tenant by ID

@@ -6962,7 +7104,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-integration-sre",
  • "description": "SRE access SSH keys for Reno Integration",
  • "org": "wdksahew1rqf",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75",
  • "siteAssociations": [
    ],
  • "sshKeys": [
    ],
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete an SSH Key Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkeygroup/{sshKeyGroupId}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-integration-sre",
  • "description": "SRE access SSH keys for Reno Integration",
  • "org": "wdksahew1rqf",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75",
  • "siteAssociations": [
    ],
  • "sshKeys": [
    ],
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete an SSH Key Group

Delete a specific SSH key Group.

@@ -6978,7 +7120,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update an SSH Key Group

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkeygroup/{sshKeyGroupId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update an SSH Key Group

Update a specific SSH Key Group.

@@ -7026,7 +7168,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "reno-int-sre",
  • "description": "SRE access SSH keys for Reno Integration Site",
  • "siteIds": [
    ],
  • "sshKeyIds": [
    ],
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-int-sre",
  • "description": "SRE access SSH keys for Reno Integration Site",
  • "org": "wdksahew1rqf",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75",
  • "siteAssociations": [
    ],
  • "sshKeys": [
    ],
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

SSH Key

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkeygroup/{sshKeyGroupId}

Request samples

Content type
application/json
{
  • "name": "reno-int-sre",
  • "description": "SRE access SSH keys for Reno Integration Site",
  • "siteIds": [
    ],
  • "sshKeyIds": [
    ],
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-int-sre",
  • "description": "SRE access SSH keys for Reno Integration Site",
  • "org": "wdksahew1rqf",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "version": "fbc692b61ffef6fbfc38a3833f6b7e7ae508da75",
  • "siteAssociations": [
    ],
  • "sshKeys": [
    ],
  • "status": "Syncing",
  • "statusHistory": [
    ],
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

SSH Key

SSH Key is a public key that can be used to access the Serial Console of an Instance.

Retrieve all SSH Keys

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    }
]

Create SSH Key

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkey

Response samples

Content type
application/json
[
  • {
    }
]

Create SSH Key

Create an SSH Key for the current Tenant. If an SSH Key Group is specified, all Sites associated with the SSH Key Group must be online.

Org must have a Tenant entity. User must have FORGE_TENANT_ADMIN authorization role.

@@ -7086,7 +7228,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "reno-sre-access",
  • "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICip4hl6WjuVHs60PeikVUs0sWE/kPhk2D0rRHWsIuyL jdoe@test.com",
  • "sshKeyGroupId": "86ca8cab-b285-4c2d-9e00-25c88810dc2e"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-sre-access",
  • "org": "xskkpgqpeakn",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "fingerprint": "CaK2yoj5fDOhf1swM2kFyjQrd3bwZfDYlWnVjBHgveQ",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve an SSH key

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkey

Request samples

Content type
application/json
{
  • "name": "reno-sre-access",
  • "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICip4hl6WjuVHs60PeikVUs0sWE/kPhk2D0rRHWsIuyL jdoe@test.com",
  • "sshKeyGroupId": "86ca8cab-b285-4c2d-9e00-25c88810dc2e"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-sre-access",
  • "org": "xskkpgqpeakn",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "fingerprint": "CaK2yoj5fDOhf1swM2kFyjQrd3bwZfDYlWnVjBHgveQ",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Retrieve an SSH key

Retrieve an SSH key for the current Tenant by ID

@@ -7108,7 +7250,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "staging-sre-access",
  • "org": "xskkpgqpeakn",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "fingerprint": "CaK2yoj5fDOhf1swM2kFyjQrd3bwZfDYlWnVjBHgveQ",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete an SSH Key

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkey/{sshKeyId}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "staging-sre-access",
  • "org": "xskkpgqpeakn",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "fingerprint": "CaK2yoj5fDOhf1swM2kFyjQrd3bwZfDYlWnVjBHgveQ",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Delete an SSH Key

Delete an SSH key for the current Tenant by ID.

@@ -7124,7 +7266,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update an SSH Key

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkey/{sshKeyId}

Response samples

Content type
application/json
{
  • "source": "carbide",
  • "message": "User is not allowed to perform this action",
  • "data": null
}

Update an SSH Key

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Request samples

Content type
application/json
{
  • "name": "reno-sre-access-v2"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-sre-access-v2",
  • "org": "xskkpgqpeakn",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "fingerprint": "CaK2yoj5fDOhf1swM2kFyjQrd3bwZfDYlWnVjBHgveQ",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

User

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/sshkey/{sshKeyId}

Request samples

Content type
application/json
{
  • "name": "reno-sre-access-v2"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "reno-sre-access-v2",
  • "org": "xskkpgqpeakn",
  • "tenantId": "f97df110-f4de-492e-8849-4a6af68026b0",
  • "fingerprint": "CaK2yoj5fDOhf1swM2kFyjQrd3bwZfDYlWnVjBHgveQ",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

User

User is a logical entity that identifies individuals operating on behalf of an organization.

Retrieve Current User

Retrieve details of the current user.

@@ -7166,7 +7308,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

The date that the user was created.

updated
string <date-time>

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "email": "janed@nvidia.com",
  • "firstName": "Jane",
  • "lastName": "Doe",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Audit

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/user/current

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "email": "janed@nvidia.com",
  • "firstName": "Jane",
  • "lastName": "Doe",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z"
}

Audit

Audit is a record of actions taken by users on the API.

Retrieve all Audit Log Entries

Typical API Call Flow for Tenant " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Retrieve Audit Log Entry

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/audit

Response samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Retrieve Audit Log Entry

Retrieve a specific Audit Log Entry by ID

User must have FORGE_PROVIDER_ADMIN or FORGE_TENANT_ADMIN authorization role

@@ -7262,7 +7404,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

Error response when user is not authorized to call an endpoint or retrieve/modify objects

Response samples

Content type
application/json
{
  • "id": "e313b3ca-c47a-4ec1-a79b-a147fad51a50",
  • "endpoint": "/v2/org/test-org-1/carbide/ep",
  • "queryParams": "{\"test\":[\"1234\"]}",
  • "method": "POST",
  • "body": "{\"key1\":\"value1\"}",
  • "statusCode": 200,
  • "clientIP": "12.123.43.112",
  • "userID": "5d9fe319-14d4-40e3-8e5a-7d79e680d55b",
  • "user": {
    },
  • "orgName": "test-org-1",
  • "timestamp": "2024-12-04T21:06:33.849293-08:00",
  • "durationMs": 250,
  • "apiVersion": "0.1.91"
}

Metadata

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/audit/{auditEntryId}

Response samples

Content type
application/json
{
  • "id": "e313b3ca-c47a-4ec1-a79b-a147fad51a50",
  • "endpoint": "/v2/org/test-org-1/carbide/ep",
  • "queryParams": "{\"test\":[\"1234\"]}",
  • "method": "POST",
  • "body": "{\"key1\":\"value1\"}",
  • "statusCode": 200,
  • "clientIP": "12.123.43.112",
  • "userID": "5d9fe319-14d4-40e3-8e5a-7d79e680d55b",
  • "user": {
    },
  • "orgName": "test-org-1",
  • "timestamp": "2024-12-04T21:06:33.849293-08:00",
  • "durationMs": 250,
  • "apiVersion": "0.1.91"
}

Metadata

Metadata describes various system level attributes of the API service.

Retrieve metadata about the API server

Retrieve system metadata providing information about the API server

@@ -7276,7 +7418,7 @@

Typical API Call Flow for Tenant

" class="sc-iKGpAq sc-cCYyou dXXcln cFvDiF">

Date/time when the API was built

Response samples

Content type
application/json
{
  • "version": "0.1.24",
  • "buildTime": "2019-08-24T14:15:22Z"
}

Deprecations

https://carbide-rest-api.carbide.svc.cluster.local/v2/org/{org}/carbide/metadata

Response samples

Content type
application/json
{
  • "version": "0.1.24",
  • "buildTime": "2019-08-24T14:15:22Z"
}

Deprecations

Typical API Call Flow for Tenant