From cd801854a34bbf9c54c041509a5fc95b48080ab3 Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Wed, 1 Jul 2026 14:23:28 -0700 Subject: [PATCH 01/13] feat: manage TargetedInstanceCreation via TenantAccount siteCapabilities Move privileged tenant capability configuration to TenantAccount.config and TenantSite.config, expose it on TenantAccount GET/PATCH, and enforce effective site-scoped checks on instance, machine, and VPC actions while deprecating the legacy Tenant.capabilities field. --- rest-api/api/pkg/api/handler/instance.go | 16 +- rest-api/api/pkg/api/handler/machine.go | 27 +-- .../api/pkg/api/handler/serviceaccount.go | 36 ++- rest-api/api/pkg/api/handler/tenantaccount.go | 227 ++++++++++++++++-- .../api/pkg/api/handler/util/common/common.go | 70 +++++- .../api/handler/util/common/common_test.go | 81 +++++++ rest-api/api/pkg/api/handler/vpc.go | 9 +- .../api/pkg/api/model/operatingsystem_test.go | 2 +- .../api/pkg/api/model/sshkeygroup_test.go | 2 +- rest-api/api/pkg/api/model/tenant.go | 19 ++ rest-api/api/pkg/api/model/tenantaccount.go | 13 +- .../api/pkg/api/model/tenantaccount_test.go | 2 +- .../api/model/tenantaccountsitecapability.go | 206 ++++++++++++++++ rest-api/db/pkg/db/model/tenantaccount.go | 28 +++ rest-api/db/pkg/db/model/tenantsite.go | 59 +++-- rest-api/db/pkg/db/model/tenantsite_test.go | 32 ++- rest-api/db/pkg/db/model/testing.go | 2 +- .../migrations/20230412224951_tenant_site.go | 2 +- ...120000_tenant_account_site_capabilities.go | 47 ++++ rest-api/openapi/spec.yaml | 54 ++++- rest-api/sdk/standard/api_tenant_account.go | 16 +- rest-api/sdk/standard/client.go | 5 +- .../model_batch_instance_create_request.go | 5 +- .../sdk/standard/model_expected_machine.go | 5 +- .../model_expected_machine_create_request.go | 5 +- .../model_expected_machine_update_request.go | 5 +- .../standard/model_expected_power_shelf.go | 3 +- ...del_expected_power_shelf_create_request.go | 3 +- ...del_expected_power_shelf_update_request.go | 3 +- rest-api/sdk/standard/model_expected_rack.go | 5 +- .../model_expected_rack_create_request.go | 5 +- .../model_expected_rack_update_request.go | 5 +- .../sdk/standard/model_expected_switch.go | 3 +- .../model_expected_switch_create_request.go | 3 +- .../model_expected_switch_update_request.go | 3 +- .../standard/model_infini_band_partition.go | 5 +- ...el_infini_band_partition_create_request.go | 3 +- ...el_infini_band_partition_update_request.go | 5 +- rest-api/sdk/standard/model_instance.go | 5 +- .../standard/model_instance_create_request.go | 5 +- rest-api/sdk/standard/model_instance_type.go | 3 +- .../model_instance_type_create_request.go | 3 +- .../model_instance_type_update_request.go | 5 +- .../standard/model_instance_update_request.go | 5 +- rest-api/sdk/standard/model_interface.go | 3 +- .../model_interface_create_request.go | 3 +- rest-api/sdk/standard/model_machine.go | 5 +- .../standard/model_machine_update_request.go | 5 +- .../standard/model_network_security_group.go | 3 +- ...l_network_security_group_create_request.go | 5 +- ...l_network_security_group_update_request.go | 5 +- rest-api/sdk/standard/model_tenant.go | 37 +++ rest-api/sdk/standard/model_tenant_account.go | 37 +++ .../model_tenant_account_site_capability.go | 225 +++++++++++++++++ ...el_tenant_account_site_capability_scope.go | 113 +++++++++ .../model_tenant_account_update_request.go | 165 +++++++++++++ .../sdk/standard/model_tenant_capabilities.go | 6 +- rest-api/sdk/standard/model_vpc.go | 5 +- .../sdk/standard/model_vpc_create_request.go | 5 +- .../sdk/standard/model_vpc_update_request.go | 5 +- 60 files changed, 1472 insertions(+), 202 deletions(-) create mode 100644 rest-api/api/pkg/api/model/tenantaccountsitecapability.go create mode 100644 rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go create mode 100644 rest-api/sdk/standard/model_tenant_account_site_capability.go create mode 100644 rest-api/sdk/standard/model_tenant_account_site_capability_scope.go create mode 100644 rest-api/sdk/standard/model_tenant_account_update_request.go diff --git a/rest-api/api/pkg/api/handler/instance.go b/rest-api/api/pkg/api/handler/instance.go index 55cfc96a8b..c821858360 100644 --- a/rest-api/api/pkg/api/handler/instance.go +++ b/rest-api/api/pkg/api/handler/instance.go @@ -892,7 +892,12 @@ func (cih CreateInstanceHandler) Handle(c echo.Context) error { // Begin validating Machine ID if apiRequest.MachineID != nil { - if tenant.Config == nil || !tenant.Config.TargetedInstanceCreation { + enabledForSite, derr := common.EffectiveTargetedInstanceCreation(ctx, tx, cih.dbSession, tenant, site.ID) + if derr != nil { + logger.Error().Err(derr).Msg("error checking effective targeted instance creation for Site") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to verify capability for Site", nil) + } + if !enabledForSite { logger.Warn().Msg("tenant does not have capability to create instances from specific machine") return cutil.NewAPIError(http.StatusForbidden, "Tenant does not have capability to create Instances using specific Machine ID", nil) } @@ -4904,8 +4909,13 @@ func (dih DeleteInstanceHandler) Handle(c echo.Context) error { // capability. By the time `ToProto` runs the request is safe // to trust. if apiRequest.IsRepairTenant != nil && *apiRequest.IsRepairTenant { - if instance.Tenant.Config == nil || !instance.Tenant.Config.TargetedInstanceCreation { - logger.Warn().Msg("tenant does not have capability to set IsRepairTenant") + enabledForSite, derr := common.EffectiveTargetedInstanceCreation(ctx, tx, dih.dbSession, instance.Tenant, instance.SiteID) + if derr != nil { + logger.Error().Err(derr).Msg("error checking effective targeted instance creation for Instance's Site") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to verify capability for Instance's Site", nil) + } + if !enabledForSite { + logger.Warn().Msg("tenant does not have capability to set IsRepairTenant for the Instance's Site") return cutil.NewAPIError(http.StatusForbidden, "Tenant does not have capability to set IsRepairTenant", nil) } } diff --git a/rest-api/api/pkg/api/handler/machine.go b/rest-api/api/pkg/api/handler/machine.go index 892ae271f1..2a3015f12a 100644 --- a/rest-api/api/pkg/api/handler/machine.go +++ b/rest-api/api/pkg/api/handler/machine.go @@ -242,7 +242,7 @@ func (gamh GetAllMachineHandler) Handle(c echo.Context) error { if tenant != nil { // Check if Tenant is privileged - if tenant.Config.TargetedInstanceCreation { + if common.TenantHasTargetedInstanceCreation(tenant) { // Get IDs for all Providers the privileged Tenant has an account with taDAO := cdbm.NewTenantAccountDAO(gamh.dbSession) tas, _, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ @@ -615,7 +615,7 @@ func (gmh GetMachineHandler) Handle(c echo.Context) error { isProviderOrPrivilegedTenant = true } else if tenant != nil { // Check if Tenant is privileged - if tenant.Config.TargetedInstanceCreation { + if common.TenantHasTargetedInstanceCreation(tenant) { // Check if privileged Tenant has an account with Infrastructure Provider taDAO := cdbm.NewTenantAccountDAO(gmh.dbSession) _, taCount, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ @@ -759,22 +759,13 @@ func (umh UpdateMachineHandler) Handle(c echo.Context) error { // Validate if Tenant is allowed to update Machine if tenant != nil { // Check if Tenant is privileged - if tenant.Config.TargetedInstanceCreation { - // Check if privileged Tenant has an account with Infrastructure Provider - taDAO := cdbm.NewTenantAccountDAO(umh.dbSession) - _, taCount, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ - InfrastructureProviderID: &machine.InfrastructureProviderID, - TenantIDs: []uuid.UUID{tenant.ID}, - }, cdbp.PageInput{}, []string{}) - if serr != nil { - logger.Error().Err(serr).Msg("error retrieving Tenant Accounts for org's Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error retrieving Tenant Accounts for org's Tenant, DB error", nil) - } - if taCount == 0 { - logger.Error().Msg("privileged Tenant doesn't have an account with Infrastructure Provider") - } else { - isPrivilegedTenant = true - } + enabledForSite, serr := common.EffectiveTargetedInstanceCreation(ctx, nil, umh.dbSession, tenant, machine.SiteID) + if serr != nil { + logger.Error().Err(serr).Msg("error checking effective targeted instance creation for Machine's Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error verifying capability for Machine's Site, DB error", nil) + } + if enabledForSite { + isPrivilegedTenant = true } } diff --git a/rest-api/api/pkg/api/handler/serviceaccount.go b/rest-api/api/pkg/api/handler/serviceaccount.go index 7004790958..88c78ae9d1 100644 --- a/rest-api/api/pkg/api/handler/serviceaccount.go +++ b/rest-api/api/pkg/api/handler/serviceaccount.go @@ -97,16 +97,11 @@ func (gcsah GetCurrentServiceAccountHandler) Handle(c echo.Context) error { } if len(tns) == 0 { - // Create Tenant - tenantConfig := &cdbm.TenantConfig{ - // Enable targeted instance creation for org - TargetedInstanceCreation: true, - } + // Create Tenant without legacy capability flags; capability is managed on TenantAccount. tn, serr = tnDAO.Create(ctx, nil, cdbm.TenantCreateInput{ Name: org, Org: org, OrgDisplayName: cutil.GetPtr(org), - Config: tenantConfig, CreatedBy: dbUser.ID, }) if serr != nil { @@ -115,23 +110,10 @@ func (gcsah GetCurrentServiceAccountHandler) Handle(c echo.Context) error { } } else { tn = &tns[0] - - // Check if Tenant has targeted instance creation enabled - if !tn.Config.TargetedInstanceCreation { - // Update Tenant to enable targeted instance creation - tenantConfig := tn.Config - tenantConfig.TargetedInstanceCreation = true - tn, serr = tnDAO.Update(ctx, nil, cdbm.TenantUpdateInput{ - TenantID: tn.ID, - Config: tenantConfig, - }) - if serr != nil { - logger.Error().Err(serr).Msg("error updating Tenant DB entity") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update Tenant capabilities for org, DB error", nil) - } - } } + accountConfig := &cdbm.TenantAccountConfig{TargetedInstanceCreation: true} + // Create Tenant Account if it doesn't exist taDAO := cdbm.NewTenantAccountDAO(gcsah.dbSession) tas, _, err := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ @@ -153,6 +135,7 @@ func (gcsah GetCurrentServiceAccountHandler) Handle(c echo.Context) error { InfrastructureProviderID: ip.ID, InfrastructureProviderOrg: ip.Org, Status: cdbm.TenantAccountStatusReady, + Config: accountConfig, CreatedBy: dbUser.ID, }) if derr != nil { @@ -175,6 +158,17 @@ func (gcsah GetCurrentServiceAccountHandler) Handle(c echo.Context) error { if serr != nil { return common.HandleTxError(c, logger, serr, "Failed to create Tenant Account, DB transaction error") } + } else if !tas[0].Config.TargetedInstanceCreation { + _, serr = taDAO.Update(ctx, nil, cdbm.TenantAccountUpdateInput{ + TenantAccountID: tas[0].ID, + Config: &cdbm.TenantAccountConfigUpdateInput{ + TargetedInstanceCreation: cutil.GetPtr(true), + }, + }) + if serr != nil { + logger.Error().Err(serr).Msg("error updating Tenant Account capabilities for org") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update Tenant Account capabilities for org, DB error", nil) + } } // Create response diff --git a/rest-api/api/pkg/api/handler/tenantaccount.go b/rest-api/api/pkg/api/handler/tenantaccount.go index 2e9df2574d..5a43095414 100644 --- a/rest-api/api/pkg/api/handler/tenantaccount.go +++ b/rest-api/api/pkg/api/handler/tenantaccount.go @@ -4,6 +4,7 @@ package handler import ( + "context" "encoding/json" "fmt" "net/http" @@ -11,6 +12,7 @@ import ( cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" "github.com/labstack/echo/v4" + "github.com/rs/zerolog" "go.opentelemetry.io/otel/attribute" temporalClient "go.temporal.io/sdk/client" @@ -212,7 +214,7 @@ func (ctah CreateTenantAccountHandler) Handle(c echo.Context) error { } // Create response - apiInstance := model.NewAPITenantAccount(ta, []cdbm.StatusDetail{*ssd}, 0) + apiInstance := model.NewAPITenantAccount(ta, []cdbm.StatusDetail{*ssd}, 0, nil) logger.Info().Msg("finishing API handler") @@ -442,7 +444,7 @@ func (gatah GetAllTenantAccountHandler) Handle(c echo.Context) error { if tmpTa.TenantID != nil { allocationCount = allocationCountByProviderAndTenant[tmpTa.InfrastructureProviderID][*tmpTa.TenantID] } - apiTa := model.NewAPITenantAccount(&tmpTa, ssdMap[ta.ID.String()], allocationCount) + apiTa := model.NewAPITenantAccount(&tmpTa, ssdMap[ta.ID.String()], allocationCount, nil) apiTas = append(apiTas, apiTa) } @@ -567,8 +569,14 @@ func (gtah GetTenantAccountHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Status Details for TenantAccount", nil) } + tenantSites, err := loadTenantSitesForTenantAccount(ctx, gtah.dbSession, ta) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Tenant Sites for Tenant Account") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Sites for Tenant Account", nil) + } + // Create response - apiTenantAccount := model.NewAPITenantAccount(ta, ssds, total) + apiTenantAccount := model.NewAPITenantAccount(ta, ssds, total, tenantSites) logger.Info().Msg("finishing API handler") @@ -627,11 +635,11 @@ func (utah UpdateTenantAccountHandler) Handle(c echo.Context) error { 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 TenantAccount - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role with org, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + isProviderAdmin := auth.ValidateUserRoles(dbUser, org, nil, auth.ProviderAdminRole) + isTenantAdmin := auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) + if !isProviderAdmin && !isTenantAdmin { + logger.Warn().Msg("user does not have Provider Admin or Tenant Admin role with org, access denied") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Provider Admin or Tenant Admin role with org", nil) } // Get tenant account ID from URL param @@ -645,10 +653,7 @@ func (utah UpdateTenantAccountHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Tenant Account ID in URL", nil) } - taDAO := cdbm.NewTenantAccountDAO(utah.dbSession) - // Validate request - // Bind request data to API model apiRequest := model.APITenantAccountUpdateRequest{} err = c.Bind(&apiRequest) if err != nil { @@ -656,35 +661,73 @@ func (utah UpdateTenantAccountHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request data, potentially invalid structure", nil) } - // Validate request attributes verr := apiRequest.Validate() if verr != nil { logger.Warn().Err(verr).Msg("error validating Tenant Account update request data") return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating Tenant Account update request data", verr) } - // Check that TenantAccount exists + hasSiteCapabilities := apiRequest.HasSiteCapabilities() + hasTenantContact := apiRequest.TenantContactID != nil + if hasSiteCapabilities && hasTenantContact { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Request cannot include both tenantContactId and siteCapabilities", nil) + } + + if hasSiteCapabilities { + if !isProviderAdmin { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Provider Admin role with org", nil) + } + if verr = apiRequest.SiteCapabilities.Validate(); verr != nil { + logger.Warn().Err(verr).Msg("error validating Tenant Account siteCapabilities") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating Tenant Account siteCapabilities", verr) + } + return utah.handleProviderSiteCapabilitiesUpdate(c, ctx, logger, org, dbUser, taID, apiRequest) + } + + if !isTenantAdmin { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + } + + return utah.handleTenantInviteAcceptance(c, ctx, logger, org, dbUser, taID, apiRequest) +} + +func loadTenantSitesForTenantAccount(ctx context.Context, dbSession *cdb.Session, ta *cdbm.TenantAccount) ([]cdbm.TenantSite, error) { + if ta == nil || ta.TenantID == nil { + return nil, nil + } + + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tenantSites, _, err := tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{*ta.TenantID}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{"Site"}) + if err != nil { + return nil, err + } + + return tenantSites, nil +} + +func (utah UpdateTenantAccountHandler) handleTenantInviteAcceptance(c echo.Context, ctx context.Context, logger zerolog.Logger, org string, dbUser *cdbm.User, taID uuid.UUID, apiRequest model.APITenantAccountUpdateRequest) error { + taDAO := cdbm.NewTenantAccountDAO(utah.dbSession) + ta, err := taDAO.GetByID(ctx, nil, taID, nil) if err != nil { logger.Warn().Err(err).Msg("error retrieving TenantAccount DB entity") return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Could not retrieve TenantAccount to update", nil) } - // Check that the org's tenant matches tenant in tenant-account tn, err := common.GetTenantForOrg(ctx, nil, utah.dbSession, org) if err != nil { logger.Warn().Err(err).Msg("tenant does not exist for org") return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Org does not have tenant", nil) } - // CHeck that Tenant in TenantAccount matches tenant in org if ta.TenantID == nil || *ta.TenantID != tn.ID { logger.Warn().Msg("tenant in tenant account does not match tenant in org") return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant in org does not match tenant in TenantAccount", nil) } - // Check that the tenant contact id if exists, matches the requesting user if apiRequest.TenantContactID != nil { tnContactID, err1 := uuid.Parse(*apiRequest.TenantContactID) if err1 != nil { @@ -697,7 +740,6 @@ func (utah UpdateTenantAccountHandler) Handle(c echo.Context) error { } } - // Check that the tenant account status is invited if ta.Status != cdbm.TenantAccountStatusInvited { logger.Warn().Msg("tenant account status is not Invited") return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant Account status is not Invited", nil) @@ -735,8 +777,155 @@ func (utah UpdateTenantAccountHandler) Handle(c echo.Context) error { return common.HandleTxError(c, logger, err, "Failed to update Tenant Account, DB transaction error") } - // Create response - apiInstance := model.NewAPITenantAccount(uta, ssds, 0) + apiInstance := model.NewAPITenantAccount(uta, ssds, 0, nil) + + logger.Info().Msg("finishing API handler") + + return c.JSON(http.StatusOK, apiInstance) +} + +func (utah UpdateTenantAccountHandler) handleProviderSiteCapabilitiesUpdate(c echo.Context, ctx context.Context, logger zerolog.Logger, org string, dbUser *cdbm.User, taID uuid.UUID, apiRequest model.APITenantAccountUpdateRequest) error { + taDAO := cdbm.NewTenantAccountDAO(utah.dbSession) + + ta, err := taDAO.GetByID(ctx, nil, taID, nil) + if err != nil { + logger.Warn().Err(err).Msg("error retrieving TenantAccount DB entity") + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Could not retrieve TenantAccount to update", nil) + } + + ip, err := common.GetInfrastructureProviderForOrg(ctx, nil, utah.dbSession, org) + if err != nil { + logger.Warn().Err(err).Msg("error getting infrastructure provider for org") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to retrieve Infrastructure Provider for current org", nil) + } + if ip.ID != ta.InfrastructureProviderID { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant Account is not associated with org", nil) + } + if ta.TenantID == nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant Account does not have an associated Tenant", nil) + } + + err = cdb.WithTx(ctx, utah.dbSession, func(tx *cdb.Tx) error { + lockKey := fmt.Sprintf("tenant-account-capabilities-%s-%s", ta.TenantID.String(), ta.InfrastructureProviderID.String()) + derr := tx.TryAcquireAdvisoryLock(ctx, cdb.GetAdvisoryLockIDFromString(lockKey), nil) + if derr != nil { + logger.Error().Err(derr).Msg("unable to acquire advisory lock for TenantAccount siteCapabilities update") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Tenant Account capabilities", nil) + } + + globalVal := model.GlobalTargetedInstanceCreationFromRequest(apiRequest.SiteCapabilities) + if globalVal == nil { + return cutil.NewAPIError(http.StatusBadRequest, "siteCapabilities must include exactly one global entry", nil) + } + + _, derr = taDAO.Update(ctx, tx, cdbm.TenantAccountUpdateInput{ + TenantAccountID: taID, + Config: &cdbm.TenantAccountConfigUpdateInput{ + TargetedInstanceCreation: globalVal, + }, + }) + if derr != nil { + logger.Error().Err(derr).Msg("error updating TenantAccount config in DB") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Tenant Account capabilities", nil) + } + + tsDAO := cdbm.NewTenantSiteDAO(utah.dbSession) + limitedUpdates := map[uuid.UUID]bool{} + limitedSiteIDs := map[uuid.UUID]struct{}{} + + for _, cap := range apiRequest.SiteCapabilities { + if cap.Scope != model.TenantAccountSiteCapabilityScopeLimited { + continue + } + for _, siteIDStr := range cap.SiteIDs { + siteID, perr := uuid.Parse(siteIDStr) + if perr != nil { + return cutil.NewAPIError(http.StatusBadRequest, "Invalid Site ID in siteCapabilities", nil) + } + limitedUpdates[siteID] = cap.TargetedInstanceCreation + limitedSiteIDs[siteID] = struct{}{} + } + } + + for siteID := range limitedUpdates { + ts, gerr := tsDAO.GetByTenantIDAndSiteID(ctx, tx, *ta.TenantID, siteID, []string{"Site"}) + if gerr != nil { + if gerr == cdb.ErrDoesNotExist { + return cutil.NewAPIError(http.StatusBadRequest, fmt.Sprintf("Site %s is not associated with Tenant", siteID.String()), nil) + } + logger.Error().Err(gerr).Msg("error retrieving TenantSite for siteCapabilities update") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Tenant Account capabilities", nil) + } + if ts.Site == nil || ts.Site.InfrastructureProviderID != ta.InfrastructureProviderID { + return cutil.NewAPIError(http.StatusBadRequest, fmt.Sprintf("Site %s is not owned by the Tenant Account Infrastructure Provider", siteID.String()), nil) + } + + _, derr = tsDAO.Update(ctx, tx, cdbm.TenantSiteUpdateInput{ + TenantSiteID: ts.ID, + Config: &cdbm.TenantSiteConfigUpdateInput{ + TargetedInstanceCreation: cutil.GetPtr(limitedUpdates[siteID]), + }, + }) + if derr != nil { + logger.Error().Err(derr).Msg("error updating TenantSite config for siteCapabilities") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Tenant Account capabilities", nil) + } + } + + allTenantSites, _, gerr := tsDAO.GetAll(ctx, tx, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{*ta.TenantID}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{"Site"}) + if gerr != nil { + logger.Error().Err(gerr).Msg("error retrieving Tenant Sites for capability override cleanup") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Tenant Account capabilities", nil) + } + + for _, ts := range allTenantSites { + if ts.Site == nil || ts.Site.InfrastructureProviderID != ta.InfrastructureProviderID { + continue + } + if _, listed := limitedSiteIDs[ts.SiteID]; listed { + continue + } + if ts.Config.TargetedInstanceCreation == nil { + continue + } + _, derr = tsDAO.Update(ctx, tx, cdbm.TenantSiteUpdateInput{ + TenantSiteID: ts.ID, + RemoveTargetedInstanceCreation: true, + }) + if derr != nil { + logger.Error().Err(derr).Msg("error clearing stale TenantSite capability override") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Tenant Account capabilities", nil) + } + } + + return nil + }) + if err != nil { + return common.HandleTxError(c, logger, err, "Failed to update Tenant Account capabilities") + } + + ta, err = taDAO.GetByID(ctx, nil, taID, nil) + if err != nil { + logger.Error().Err(err).Msg("error retrieving updated TenantAccount") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve updated Tenant Account", nil) + } + + tenantSites, err := loadTenantSitesForTenantAccount(ctx, utah.dbSession, ta) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Tenant Sites for updated Tenant Account") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Sites for Tenant Account", nil) + } + + sdDAO := cdbm.NewStatusDetailDAO(utah.dbSession) + ssds, _, err := sdDAO.GetAll(ctx, nil, cdbm.StatusDetailFilterInput{EntityIDs: []string{ta.ID.String()}}, cdbp.PageInput{Limit: cutil.GetPtr(pagination.MaxPageSize)}) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Status Details for TenantAccount from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Status Details for TenantAccount", nil) + } + + apiInstance := model.NewAPITenantAccount(ta, ssds, 0, tenantSites) logger.Info().Msg("finishing API handler") diff --git a/rest-api/api/pkg/api/handler/util/common/common.go b/rest-api/api/pkg/api/handler/util/common/common.go index 1e9f289171..e6dac671b1 100644 --- a/rest-api/api/pkg/api/handler/util/common/common.go +++ b/rest-api/api/pkg/api/handler/util/common/common.go @@ -1494,13 +1494,79 @@ func IsTenant(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session return nil, cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve tenant for org, DB error", nil) } - if requirePrivileged && !tenant.Config.TargetedInstanceCreation { + if requirePrivileged && !TenantHasTargetedInstanceCreation(tenant) { return nil, cutil.NewAPIError(http.StatusForbidden, "Tenant does not have Targeted Instance Creation capability enabled", nil) } return tenant, nil } +// TenantHasTargetedInstanceCreation reports whether the Tenant has the +// TargetedInstanceCreation capability enabled at the tenant level (the legacy +// "ceiling"). It is nil-safe so callers don't have to repeat the tenant/Config +// nil checks. +// +// This is the coarse filter for discovery/listing paths without site context. +// Site-scoped capability actions should use EffectiveTargetedInstanceCreation. +func TenantHasTargetedInstanceCreation(tenant *cdbm.Tenant) bool { + return tenant != nil && tenant.Config != nil && tenant.Config.TargetedInstanceCreation +} + +// EffectiveTargetedInstanceCreation reports whether the TargetedInstanceCreation +// capability is effective for a Tenant on a specific Site. It resolves the +// provider-scoped global default from a Ready TenantAccount and an optional +// per-site override from TenantSite.config: +// +// effective = siteOverride ?? tenantAccount.config.targetedInstanceCreation +// +// When no Ready TenantAccount exists for the Site's Infrastructure Provider, +// the result is false. The provider relationship gate (TenantAccount existence) +// is intentionally enforced here so action paths share one resolution helper. +func EffectiveTargetedInstanceCreation(ctx context.Context, tx *cdb.Tx, dbSession *cdb.Session, tenant *cdbm.Tenant, siteID uuid.UUID) (bool, error) { + if tenant == nil { + return false, nil + } + + siteDAO := cdbm.NewSiteDAO(dbSession) + site, err := siteDAO.GetByID(ctx, tx, siteID, nil, false) + if err != nil { + if errors.Is(err, cdb.ErrDoesNotExist) { + return false, nil + } + return false, err + } + + taDAO := cdbm.NewTenantAccountDAO(dbSession) + tas, _, err := taDAO.GetAll(ctx, tx, cdbm.TenantAccountFilterInput{ + InfrastructureProviderID: &site.InfrastructureProviderID, + TenantIDs: []uuid.UUID{tenant.ID}, + Statuses: []string{cdbm.TenantAccountStatusReady}, + }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) + if err != nil { + return false, err + } + if len(tas) == 0 { + return false, nil + } + + global := tas[0].Config.TargetedInstanceCreation + + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + ts, err := tsDAO.GetByTenantIDAndSiteID(ctx, tx, tenant.ID, siteID, nil) + if err != nil { + if errors.Is(err, cdb.ErrDoesNotExist) { + return global, nil + } + return false, err + } + + if ts.Config.TargetedInstanceCreation != nil { + return *ts.Config.TargetedInstanceCreation, nil + } + + return global, nil +} + // IsProviderOrTenant ensures that user is authorized to act as a Provider Admin or/and Tenant Admin for the org. // if authorized it returns the tenant otherwise a relevant error. func IsProviderOrTenant(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, org string, user *cdbm.User, allowViewerRole bool, requirePrivilegedTenant bool) (infrastructureProvider *cdbm.InfrastructureProvider, tenant *cdbm.Tenant, apiError *cutil.APIError) { @@ -1552,7 +1618,7 @@ func IsProviderOrTenant(ctx context.Context, logger zerolog.Logger, dbSession *c } if tenant != nil && requirePrivilegedTenant { - if !tenant.Config.TargetedInstanceCreation { + if !TenantHasTargetedInstanceCreation(tenant) { if infrastructureProvider == nil { return nil, nil, cutil.NewAPIError(http.StatusForbidden, "Tenant does not have targeted Instance creation capability enabled", nil) } diff --git a/rest-api/api/pkg/api/handler/util/common/common_test.go b/rest-api/api/pkg/api/handler/util/common/common_test.go index c0286bc84a..d95cbac3da 100644 --- a/rest-api/api/pkg/api/handler/util/common/common_test.go +++ b/rest-api/api/pkg/api/handler/util/common/common_test.go @@ -53,6 +53,9 @@ func testCommonSetupSchema(t *testing.T, dbSession *cdb.Session) { // create Tenant table err = dbSession.DB.ResetModel(context.Background(), (*cdbm.Tenant)(nil)) assert.Nil(t, err) + // create TenantAccount table + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.TenantAccount)(nil)) + assert.Nil(t, err) // create TenantSite table err = dbSession.DB.ResetModel(context.Background(), (*cdbm.TenantSite)(nil)) assert.Nil(t, err) @@ -2863,3 +2866,81 @@ func TestEvaluateInfiniBandRequestAgainstMachineCaps(t *testing.T) { assert.Equal(t, []int{0, 2}, match.SuggestedByDevice["MT28908 Family [ConnectX-6]"]) }) } + +func TestTenantHasTargetedInstanceCreation(t *testing.T) { + tests := []struct { + name string + tenant *cdbm.Tenant + expected bool + }{ + {name: "nil tenant", tenant: nil, expected: false}, + {name: "nil config", tenant: &cdbm.Tenant{Config: nil}, expected: false}, + {name: "disabled", tenant: &cdbm.Tenant{Config: &cdbm.TenantConfig{}}, expected: false}, + {name: "enabled", tenant: &cdbm.Tenant{Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}}, expected: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, TenantHasTargetedInstanceCreation(tc.tenant)) + }) + } +} + +func TestEffectiveTargetedInstanceCreation(t *testing.T) { + ctx := context.Background() + dbSession := testCommonInitDB(t) + defer dbSession.Close() + + testCommonSetupSchema(t, dbSession) + + org := "test-eff-org" + user := testCommonBuildUser(t, dbSession, uuid.NewString(), []string{org}, []string{authz.ProviderAdminRole}) + ip := testCommonBuildInfrastructureProvider(t, dbSession, "Test Provider", org, user) + site := testCommonBuildSite(t, dbSession, ip, "Test Site", user) + site2 := testCommonBuildSite(t, dbSession, ip, "Test Site 2", user) + site3 := testCommonBuildSite(t, dbSession, ip, "Test Site 3", user) + + tnDAO := cdbm.NewTenantDAO(dbSession) + tenant, err := tnDAO.Create(ctx, nil, cdbm.TenantCreateInput{ + Name: "tenant", + Org: org + "-tenant", + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + taDAO := cdbm.NewTenantAccountDAO(dbSession) + _, err = taDAO.Create(ctx, nil, cdbm.TenantAccountCreateInput{ + AccountNumber: uuid.NewString(), + TenantID: &tenant.ID, + TenantOrg: tenant.Org, + InfrastructureProviderID: ip.ID, + InfrastructureProviderOrg: ip.Org, + Status: cdbm.TenantAccountStatusReady, + Config: &cdbm.TenantAccountConfig{TargetedInstanceCreation: true}, + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + cdbm.TestBuildTenantSite(t, dbSession, tenant, site, nil, user) + ts2 := cdbm.TestBuildTenantSite(t, dbSession, tenant, site2, &cdbm.TenantSiteConfig{TargetedInstanceCreation: cutil.GetPtr(false)}, user) + + tests := []struct { + name string + siteID uuid.UUID + expected bool + }{ + {name: "global default when no override", siteID: site.ID, expected: true}, + {name: "limited override disables site", siteID: site2.ID, expected: false}, + {name: "global default when no tenant_site override", siteID: site3.ID, expected: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, gerr := EffectiveTargetedInstanceCreation(ctx, nil, dbSession, tenant, tc.siteID) + assert.Nil(t, gerr) + assert.Equal(t, tc.expected, got) + }) + } + + _ = ts2 +} diff --git a/rest-api/api/pkg/api/handler/vpc.go b/rest-api/api/pkg/api/handler/vpc.go index c0d05a84a1..d3093d89ca 100644 --- a/rest-api/api/pkg/api/handler/vpc.go +++ b/rest-api/api/pkg/api/handler/vpc.go @@ -238,8 +238,13 @@ func (cvh CreateVPCHandler) Handle(c echo.Context) error { var routingProfile *string if apiRequest.RoutingProfile != nil { // For now, we gate on TargetedInstanceCreation permission, - // Which implies a "privileged tenant" - if tenant.Config == nil || !tenant.Config.TargetedInstanceCreation { + // which must be effective for the VPC's Site. + enabledForSite, derr := common.EffectiveTargetedInstanceCreation(ctx, nil, cvh.dbSession, tenant, site.ID) + if derr != nil { + logger.Error().Err(derr).Msg("error checking effective targeted instance creation for Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify privileges for Site", nil) + } + if !enabledForSite { logger.Warn().Msg("tenant does not have sufficient privileges to set `routingProfile`") return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant does not have sufficient privileges to set `routingProfile`", nil) } diff --git a/rest-api/api/pkg/api/model/operatingsystem_test.go b/rest-api/api/pkg/api/model/operatingsystem_test.go index 2441b6ed72..42a965c0d3 100644 --- a/rest-api/api/pkg/api/model/operatingsystem_test.go +++ b/rest-api/api/pkg/api/model/operatingsystem_test.go @@ -891,7 +891,7 @@ func TestAPIOperatingSystemNew(t *testing.T) { TenantOrg: "test", SiteID: dbossa[0].SiteID, EnableSerialConsole: true, - Config: map[string]interface{}{}, + Config: cdbm.TenantSiteConfig{}, Created: cdb.GetCurTime(), Updated: cdb.GetCurTime(), }, diff --git a/rest-api/api/pkg/api/model/sshkeygroup_test.go b/rest-api/api/pkg/api/model/sshkeygroup_test.go index c4f1475a98..e046669c4f 100644 --- a/rest-api/api/pkg/api/model/sshkeygroup_test.go +++ b/rest-api/api/pkg/api/model/sshkeygroup_test.go @@ -96,7 +96,7 @@ func TestAPISSHKeyGroupNew(t *testing.T) { TenantOrg: dbskg.Org, SiteID: dbsgas[0].SiteID, EnableSerialConsole: true, - Config: map[string]interface{}{}, + Config: cdbm.TenantSiteConfig{}, Created: cdb.GetCurTime(), Updated: cdb.GetCurTime(), }, diff --git a/rest-api/api/pkg/api/model/tenant.go b/rest-api/api/pkg/api/model/tenant.go index 888d38f8c7..a11b12604f 100644 --- a/rest-api/api/pkg/api/model/tenant.go +++ b/rest-api/api/pkg/api/model/tenant.go @@ -6,6 +6,7 @@ package model import ( "time" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" ) @@ -15,6 +16,18 @@ var ( // errMsgTenantUpdateEndpointDeprecated is the error message to indicate that update endpoint is deprecated ErrMsgTenantUpdateEndpointDeprecated = "PATCH '/org/:orgName/nico/tenant/current' endpoint has been deprecated" + + // Time when Tenant.capabilities.targetedInstanceCreation will be deprecated + tenantTargetedInstanceCreationDeprecationTime, _ = time.Parse(time.RFC1123, "Thu, 09 Jul 2026 00:00:00 UTC") + + tenantCapabilityDeprecations = []DeprecatedEntity{ + { + OldValue: "capabilities.targetedInstanceCreation", + NewValue: cutil.GetPtr("tenantAccount.siteCapabilities"), + Type: DeprecationTypeAttribute, + TakeActionBy: tenantTargetedInstanceCreationDeprecationTime, + }, + } ) // APITenant is the data structure to capture API representation of a Tenant @@ -31,6 +44,8 @@ type APITenant struct { Updated time.Time `json:"updated"` // Capabilities describes tenant-level feature flags Capabilities *APITenantCapabilities `json:"capabilities"` + // Deprecations is the list of deprecations for the Tenant + Deprecations []APIDeprecation `json:"deprecations"` } // NewAPITenant accepts a DB layer Tenant object returns an API layer object @@ -44,6 +59,10 @@ func NewAPITenant(dbtn *cdbm.Tenant) *APITenant { Updated: dbtn.Updated, } + for _, deprecation := range tenantCapabilityDeprecations { + atn.Deprecations = append(atn.Deprecations, NewAPIDeprecation(deprecation)) + } + return &atn } diff --git a/rest-api/api/pkg/api/model/tenantaccount.go b/rest-api/api/pkg/api/model/tenantaccount.go index 4bf2ce62a7..d80a39f9ce 100644 --- a/rest-api/api/pkg/api/model/tenantaccount.go +++ b/rest-api/api/pkg/api/model/tenantaccount.go @@ -69,6 +69,8 @@ func (tacr APITenantAccountCreateRequest) Validate() error { type APITenantAccountUpdateRequest struct { // TenantContactID is the ID of the requesting user TenantContactID *string `json:"tenantContactId"` + // SiteCapabilities replaces the provider-scoped capability configuration for the TenantAccount + SiteCapabilities APITenantAccountSiteCapabilitiesUpdateRequest `json:"siteCapabilities"` } // Validate ensure the values passed in request are acceptable @@ -79,6 +81,11 @@ func (taur APITenantAccountUpdateRequest) Validate() error { ) } +// HasSiteCapabilities reports whether the request includes a siteCapabilities replace payload. +func (taur APITenantAccountUpdateRequest) HasSiteCapabilities() bool { + return len(taur.SiteCapabilities) > 0 +} + // APITenantAccount is the data structure to capture API representation of a TenantAccount type APITenantAccount struct { // ID is the unique UUID v4 identifier for the TenantAccount @@ -115,6 +122,8 @@ type APITenantAccount struct { Updated time.Time `json:"updated"` // Deprecations is the list of deprecations for the TenantAccount Deprecations []APIDeprecation `json:"deprecations"` + // SiteCapabilities describes provider-scoped TargetedInstanceCreation settings + SiteCapabilities []APITenantAccountSiteCapability `json:"siteCapabilities,omitempty"` } // APITenantAccountStats is a data structure to capture information about a TenantAccount stats at the API layer @@ -132,7 +141,7 @@ type APITenantAccountStats struct { } // NewAPITenantAccount accepts a DB layer TenantAccount object returns an API layer object -func NewAPITenantAccount(dbta *cdbm.TenantAccount, dbsds []cdbm.StatusDetail, allocationCount int) *APITenantAccount { +func NewAPITenantAccount(dbta *cdbm.TenantAccount, dbsds []cdbm.StatusDetail, allocationCount int, tenantSites []cdbm.TenantSite) *APITenantAccount { apiTenantAccount := APITenantAccount{ ID: dbta.ID.String(), InfrastructureProviderID: dbta.InfrastructureProviderID.String(), @@ -175,5 +184,7 @@ func NewAPITenantAccount(dbta *cdbm.TenantAccount, dbsds []cdbm.StatusDetail, al apiTenantAccount.Deprecations = append(apiTenantAccount.Deprecations, NewAPIDeprecation(deprecation)) } + apiTenantAccount.SiteCapabilities = tenantAccountSiteCapabilitiesToAPI(dbta, filterTenantSitesForAccount(dbta, tenantSites)) + return &apiTenantAccount } diff --git a/rest-api/api/pkg/api/model/tenantaccount_test.go b/rest-api/api/pkg/api/model/tenantaccount_test.go index 6466cb5ce3..307eeb6987 100644 --- a/rest-api/api/pkg/api/model/tenantaccount_test.go +++ b/rest-api/api/pkg/api/model/tenantaccount_test.go @@ -229,7 +229,7 @@ func TestAPITenantAccountNew(t *testing.T) { } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - got := NewAPITenantAccount(tc.dbObj, tc.sdObj, 2) + got := NewAPITenantAccount(tc.dbObj, tc.sdObj, 2, nil) assert.Equal(t, tc.apiObj.ID, got.ID) assert.Equal(t, tc.apiObj.InfrastructureProviderID, got.InfrastructureProviderID) assert.Equal(t, tc.apiObj.InfrastructureProviderOrg, got.InfrastructureProviderOrg) diff --git a/rest-api/api/pkg/api/model/tenantaccountsitecapability.go b/rest-api/api/pkg/api/model/tenantaccountsitecapability.go new file mode 100644 index 0000000000..c89f09cb84 --- /dev/null +++ b/rest-api/api/pkg/api/model/tenantaccountsitecapability.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "fmt" + "slices" + + validation "github.com/go-ozzo/ozzo-validation/v4" + validationis "github.com/go-ozzo/ozzo-validation/v4/is" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/google/uuid" +) + +const ( + validationErrorInvalidSiteCapabilityScope = "scope must be global or limited" + validationErrorGlobalSiteIDsNotAllowed = "siteIds must be omitted or empty when scope is global" + validationErrorLimitedSiteIDsRequired = "siteIds must be specified when scope is limited" + validationErrorDuplicateGlobalScope = "only one global siteCapabilities entry is allowed" + validationErrorDuplicateSiteID = "duplicate siteIds are not allowed across siteCapabilities entries" +) + +// TenantAccountSiteCapabilityScope identifies whether a capability entry applies globally +// or to an explicit set of sites. +type TenantAccountSiteCapabilityScope string + +const ( + TenantAccountSiteCapabilityScopeGlobal TenantAccountSiteCapabilityScope = "global" + TenantAccountSiteCapabilityScopeLimited TenantAccountSiteCapabilityScope = "limited" +) + +var tenantAccountSiteCapabilityScopes = []interface{}{ + TenantAccountSiteCapabilityScopeGlobal, + TenantAccountSiteCapabilityScopeLimited, +} + +// APITenantAccountSiteCapability describes the TargetedInstanceCreation capability for +// either all sites (global) or an explicit site list (limited). +type APITenantAccountSiteCapability struct { + SiteIDs []string `json:"siteIds,omitempty"` + Scope TenantAccountSiteCapabilityScope `json:"scope"` + TargetedInstanceCreation bool `json:"targetedInstanceCreation"` +} + +// APITenantAccountSiteCapabilitiesUpdateRequest is the replace payload for Provider Admin +// capability updates on a TenantAccount. +type APITenantAccountSiteCapabilitiesUpdateRequest []APITenantAccountSiteCapability + +// Validate ensures the replace payload is structurally valid. +func (caps APITenantAccountSiteCapabilitiesUpdateRequest) Validate() error { + if len(caps) == 0 { + return validation.Errors{"siteCapabilities": fmt.Errorf("siteCapabilities must contain at least one entry")} + } + + globalCount := 0 + seenSiteIDs := map[string]struct{}{} + + for i, cap := range caps { + prefix := fmt.Sprintf("[%d]", i) + if err := validation.ValidateStruct(&cap, + validation.Field(&cap.Scope, + validation.Required.Error(validationErrorValueRequired), + validation.In(tenantAccountSiteCapabilityScopes...).Error(validationErrorInvalidSiteCapabilityScope)), + validation.Field(&cap.SiteIDs, + validation.When(cap.Scope == TenantAccountSiteCapabilityScopeGlobal, + validation.By(func(value interface{}) error { + siteIDs, ok := value.([]string) + if !ok { + return nil + } + if len(siteIDs) > 0 { + return fmt.Errorf(validationErrorGlobalSiteIDsNotAllowed) + } + return nil + })), + validation.When(cap.Scope == TenantAccountSiteCapabilityScopeLimited, + validation.By(func(value interface{}) error { + siteIDs, ok := value.([]string) + if !ok || len(siteIDs) == 0 { + return fmt.Errorf(validationErrorLimitedSiteIDsRequired) + } + return nil + }), + validation.Each(validationis.UUID.Error(validationErrorInvalidUUID)), + ), + ), + ); err != nil { + return validation.Errors{prefix: err} + } + + if cap.Scope == TenantAccountSiteCapabilityScopeGlobal { + globalCount++ + } + + for _, siteID := range cap.SiteIDs { + if _, ok := seenSiteIDs[siteID]; ok { + return validation.Errors{"siteCapabilities": fmt.Errorf(validationErrorDuplicateSiteID)} + } + seenSiteIDs[siteID] = struct{}{} + } + } + + if globalCount != 1 { + return validation.Errors{"siteCapabilities": fmt.Errorf(validationErrorDuplicateGlobalScope)} + } + + return nil +} + +func tenantAccountSiteCapabilitiesToAPI(ta *cdbm.TenantAccount, tenantSites []cdbm.TenantSite) []APITenantAccountSiteCapability { + if ta == nil { + return nil + } + + global := ta.Config.TargetedInstanceCreation + caps := []APITenantAccountSiteCapability{ + { + Scope: TenantAccountSiteCapabilityScopeGlobal, + TargetedInstanceCreation: global, + }, + } + + enabledSiteIDs := []string{} + disabledSiteIDs := []string{} + + for _, ts := range tenantSites { + if ts.Config.TargetedInstanceCreation == nil { + continue + } + override := *ts.Config.TargetedInstanceCreation + if override == global { + continue + } + if override { + enabledSiteIDs = append(enabledSiteIDs, ts.SiteID.String()) + } else { + disabledSiteIDs = append(disabledSiteIDs, ts.SiteID.String()) + } + } + + slices.Sort(enabledSiteIDs) + slices.Sort(disabledSiteIDs) + + if len(enabledSiteIDs) > 0 { + caps = append(caps, APITenantAccountSiteCapability{ + Scope: TenantAccountSiteCapabilityScopeLimited, + SiteIDs: enabledSiteIDs, + TargetedInstanceCreation: true, + }) + } + if len(disabledSiteIDs) > 0 { + caps = append(caps, APITenantAccountSiteCapability{ + Scope: TenantAccountSiteCapabilityScopeLimited, + SiteIDs: disabledSiteIDs, + TargetedInstanceCreation: false, + }) + } + + return caps +} + +func filterTenantSitesForAccount(ta *cdbm.TenantAccount, tenantSites []cdbm.TenantSite) []cdbm.TenantSite { + if ta == nil || ta.TenantID == nil { + return nil + } + + filtered := make([]cdbm.TenantSite, 0, len(tenantSites)) + for _, ts := range tenantSites { + if ts.Site != nil && ts.Site.InfrastructureProviderID == ta.InfrastructureProviderID { + filtered = append(filtered, ts) + continue + } + if ts.Site == nil { + filtered = append(filtered, ts) + } + } + return filtered +} + +func parseSiteCapabilitySiteIDs(caps APITenantAccountSiteCapabilitiesUpdateRequest) ([]uuid.UUID, error) { + siteIDs := []uuid.UUID{} + for _, cap := range caps { + if cap.Scope != TenantAccountSiteCapabilityScopeLimited { + continue + } + for _, siteIDStr := range cap.SiteIDs { + siteID, err := uuid.Parse(siteIDStr) + if err != nil { + return nil, err + } + siteIDs = append(siteIDs, siteID) + } + } + return siteIDs, nil +} + +func GlobalTargetedInstanceCreationFromRequest(caps APITenantAccountSiteCapabilitiesUpdateRequest) *bool { + for _, cap := range caps { + if cap.Scope == TenantAccountSiteCapabilityScopeGlobal { + return cutil.GetPtr(cap.TargetedInstanceCreation) + } + } + return nil +} diff --git a/rest-api/db/pkg/db/model/tenantaccount.go b/rest-api/db/pkg/db/model/tenantaccount.go index b1acaeed71..626dfbb543 100644 --- a/rest-api/db/pkg/db/model/tenantaccount.go +++ b/rest-api/db/pkg/db/model/tenantaccount.go @@ -108,6 +108,17 @@ var ( } ) +// TenantAccountConfig holds provider-scoped configuration for a TenantAccount. +// Fields must stay flat so jsonb partial merge works. +type TenantAccountConfig struct { + TargetedInstanceCreation bool `json:"targetedInstanceCreation"` +} + +// TenantAccountConfigUpdateInput carries partial updates for tenant_account.config. +type TenantAccountConfigUpdateInput struct { + TargetedInstanceCreation *bool `json:"targetedInstanceCreation,omitempty"` +} + // TenantAccount represents a tenant account - the relationship between a Tenant and an Infrastructure Provider type TenantAccount struct { bun.BaseModel `bun:"table:tenant_account,alias:ta"` @@ -125,6 +136,7 @@ type TenantAccount struct { TenantContactID *uuid.UUID `bun:"tenant_contact_id,type:uuid"` TenantContact *User `bun:"rel:belongs-to,join:tenant_contact_id=id"` Status string `bun:"status,notnull"` + Config TenantAccountConfig `bun:"config,type:jsonb,notnull,default:'{}'::jsonb"` 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"` @@ -141,6 +153,7 @@ type TenantAccountCreateInput struct { SubscriptionID *string SubscriptionTier *string Status string + Config *TenantAccountConfig CreatedBy uuid.UUID } @@ -152,6 +165,7 @@ type TenantAccountUpdateInput struct { SubscriptionTier *string TenantContactID *uuid.UUID Status *string + Config *TenantAccountConfigUpdateInput } // TenantAccountFilterInput filtering options for GetAll and GetCount method, including SearchQuery for filtering by account or tenant org @@ -467,6 +481,9 @@ func (tasd TenantAccountSQLDAO) Create(ctx context.Context, tx *db.Tx, input Ten Status: input.Status, CreatedBy: input.CreatedBy, } + if input.Config != nil { + ta.Config = *input.Config + } _, err := db.GetIDB(tx, tasd.dbSession).NewInsert().Model(ta).Exec(ctx) if err != nil { @@ -535,6 +552,17 @@ func (tasd TenantAccountSQLDAO) Update(ctx context.Context, tx *db.Tx, input Ten } } + if input.Config != nil { + _, err := db.GetIDB(tx, tasd.dbSession).NewUpdate(). + Model(ta). + Set("config = config || ?::jsonb, updated = current_timestamp", input.Config). + Where("id = ?", input.TenantAccountID.String()). + Exec(ctx) + if err != nil { + return nil, err + } + } + uta, err := tasd.GetByID(ctx, tx, input.TenantAccountID, nil) if err != nil { return nil, err diff --git a/rest-api/db/pkg/db/model/tenantsite.go b/rest-api/db/pkg/db/model/tenantsite.go index 2f10056b02..e2032ff670 100644 --- a/rest-api/db/pkg/db/model/tenantsite.go +++ b/rest-api/db/pkg/db/model/tenantsite.go @@ -30,6 +30,17 @@ var ( } ) +// TenantSiteConfig holds per-tenant-site configuration. Fields must stay flat so +// jsonb partial merge works. +type TenantSiteConfig struct { + TargetedInstanceCreation *bool `json:"targetedInstanceCreation,omitempty"` +} + +// TenantSiteConfigUpdateInput carries partial updates for tenant_site.config. +type TenantSiteConfigUpdateInput struct { + TargetedInstanceCreation *bool `json:"targetedInstanceCreation,omitempty"` +} + // TenantSite captures the relationship between a Tenant and a Site type TenantSite struct { bun.BaseModel `bun:"table:tenant_site,alias:ts"` @@ -40,9 +51,9 @@ type TenantSite struct { TenantOrg string `bun:"tenant_org,notnull"` SiteID uuid.UUID `bun:"site_id,type:uuid,notnull"` Site *Site `bun:"rel:belongs-to,join:site_id=id"` - EnableSerialConsole bool `bun:"enable_serial_console,notnull"` - Config map[string]interface{} `bun:"config,type:jsonb,json_use_number"` - Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + EnableSerialConsole bool `bun:"enable_serial_console,notnull"` + Config TenantSiteConfig `bun:"config,type:jsonb,notnull,default:'{}'::jsonb"` + 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"` @@ -53,15 +64,16 @@ type TenantSiteCreateInput struct { TenantID uuid.UUID TenantOrg string SiteID uuid.UUID - Config map[string]interface{} + Config *TenantSiteConfig CreatedBy uuid.UUID } // TenantSiteUpdateInput input parameters for Update method type TenantSiteUpdateInput struct { - TenantSiteID uuid.UUID - EnableSerialConsole *bool - Config map[string]interface{} + TenantSiteID uuid.UUID + EnableSerialConsole *bool + Config *TenantSiteConfigUpdateInput + RemoveTargetedInstanceCreation bool } type TenantSiteFilterInput struct { @@ -240,11 +252,9 @@ func (tssd TenantSiteSQLDAO) Create(ctx context.Context, tx *db.Tx, input Tenant defer tnsDAOSpan.End() } - var normConfig map[string]interface{} + var normConfig TenantSiteConfig if input.Config != nil { - normConfig = input.Config - } else { - normConfig = map[string]interface{}{} + normConfig = *input.Config } ts := &TenantSite{ @@ -292,11 +302,6 @@ func (tssd TenantSiteSQLDAO) Update(ctx context.Context, tx *db.Tx, input Tenant tssd.tracerSpan.SetAttribute(tnsDAOSpan, "enable_serial_console", *input.EnableSerialConsole) } - if input.Config != nil { - ts.Config = input.Config - updatedFields = append(updatedFields, "config") - } - if len(updatedFields) > 0 { updatedFields = append(updatedFields, "updated") @@ -306,6 +311,28 @@ func (tssd TenantSiteSQLDAO) Update(ctx context.Context, tx *db.Tx, input Tenant } } + if input.Config != nil { + _, err := db.GetIDB(tx, tssd.dbSession).NewUpdate(). + Model(ts). + Set("config = config || ?::jsonb, updated = current_timestamp", input.Config). + Where("id = ?", input.TenantSiteID). + Exec(ctx) + if err != nil { + return nil, err + } + } + + if input.RemoveTargetedInstanceCreation { + _, err := db.GetIDB(tx, tssd.dbSession).NewUpdate(). + Model(ts). + Set("config = config - 'targetedInstanceCreation', updated = current_timestamp"). + Where("id = ?", input.TenantSiteID). + Exec(ctx) + if err != nil { + return nil, err + } + } + uts, err := tssd.GetByID(ctx, tx, input.TenantSiteID, nil) if err != nil { return nil, err diff --git a/rest-api/db/pkg/db/model/tenantsite_test.go b/rest-api/db/pkg/db/model/tenantsite_test.go index 965d8e0753..38dfa29792 100644 --- a/rest-api/db/pkg/db/model/tenantsite_test.go +++ b/rest-api/db/pkg/db/model/tenantsite_test.go @@ -68,7 +68,7 @@ func TestTenantSiteSQLDAO_GetByID(t *testing.T) { tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) - ts := TestBuildTenantSite(t, dbSession, tn, site, map[string]interface{}{}, tnu) + ts := TestBuildTenantSite(t, dbSession, tn, site, nil, tnu) type fields struct { dbSession *db.Session @@ -172,7 +172,7 @@ func TestTenantSiteSQLDAO_GetByTenantIDAndSiteID(t *testing.T) { tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) - ts := TestBuildTenantSite(t, dbSession, tn, site, map[string]interface{}{}, tnu) + ts := TestBuildTenantSite(t, dbSession, tn, site, nil, tnu) type fields struct { dbSession *db.Session @@ -282,9 +282,7 @@ func TestTenantSiteSQLDAO_GetAll(t *testing.T) { tnu2 := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg2, tnRoles) tn2 := TestBuildTenant(t, dbSession, "Test Tenant 2", tnOrg2, tnu2) - config := map[string]interface{}{ - "test-key": "test-value", - } + config := &TenantSiteConfig{TargetedInstanceCreation: cutil.GetPtr(true)} sites := []*Site{} siteCount := 30 @@ -292,7 +290,7 @@ func TestTenantSiteSQLDAO_GetAll(t *testing.T) { site := TestBuildSite(t, dbSession, ip, fmt.Sprintf("test-site-%d", i), ipu) sites = append(sites, site) if i%2 == 0 { - TestBuildTenantSite(t, dbSession, tn1, site, map[string]interface{}{}, tnu1) + TestBuildTenantSite(t, dbSession, tn1, site, nil, tnu1) } else { TestBuildTenantSite(t, dbSession, tn2, site, config, tnu2) } @@ -406,8 +404,8 @@ func TestTenantSiteSQLDAO_GetAll(t *testing.T) { dbSession: dbSession, }, args: args{ - configKey: cutil.GetPtr("test-key"), - configVal: cutil.GetPtr("test-value"), + configKey: cutil.GetPtr("targetedInstanceCreation"), + configVal: cutil.GetPtr("true"), }, wantCount: siteCount / 2, wantTotalCount: siteCount / 2, @@ -513,7 +511,7 @@ func TestTenantSiteSQLDAO_Create(t *testing.T) { tenantID uuid.UUID tenantOrg string siteID uuid.UUID - config map[string]interface{} + config *TenantSiteConfig createdBy uuid.UUID } @@ -536,14 +534,14 @@ func TestTenantSiteSQLDAO_Create(t *testing.T) { tenantID: tn.ID, tenantOrg: tnOrg, siteID: site.ID, - config: map[string]interface{}{"test-key": "test-value"}, + config: &TenantSiteConfig{TargetedInstanceCreation: cutil.GetPtr(true)}, createdBy: tnu.ID, }, want: &TenantSite{ TenantID: tn.ID, TenantOrg: tnOrg, SiteID: site.ID, - Config: map[string]interface{}{"test-key": "test-value"}, + Config: TenantSiteConfig{TargetedInstanceCreation: cutil.GetPtr(true)}, CreatedBy: tnu.ID, }, verifyChildSpanner: true, @@ -564,7 +562,7 @@ func TestTenantSiteSQLDAO_Create(t *testing.T) { TenantID: tn.ID, TenantOrg: tnOrg, SiteID: site.ID, - Config: map[string]interface{}{}, + Config: TenantSiteConfig{}, CreatedBy: tnu.ID, }, }, @@ -620,7 +618,7 @@ func TestTenantSiteSQLDAO_Update(t *testing.T) { tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) - ts := TestBuildTenantSite(t, dbSession, tn, site, map[string]interface{}{}, tnu) + ts := TestBuildTenantSite(t, dbSession, tn, site, nil, tnu) type fields struct { dbSession *db.Session @@ -628,7 +626,7 @@ func TestTenantSiteSQLDAO_Update(t *testing.T) { type args struct { id uuid.UUID enableSerialConsole *bool - config map[string]interface{} + config *TenantSiteConfigUpdateInput } // OTEL Spanner configuration @@ -663,11 +661,11 @@ func TestTenantSiteSQLDAO_Update(t *testing.T) { }, args: args{ id: ts.ID, - config: map[string]interface{}{"test-key": "test-value"}, + config: &TenantSiteConfigUpdateInput{TargetedInstanceCreation: cutil.GetPtr(true)}, }, want: &TenantSite{ ID: ts.ID, - Config: map[string]interface{}{"test-key": "test-value"}, + Config: TenantSiteConfig{TargetedInstanceCreation: cutil.GetPtr(true)}, }, }, } @@ -721,7 +719,7 @@ func TestTenantSiteSQLDAO_Delete(t *testing.T) { tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) - ts := TestBuildTenantSite(t, dbSession, tn, site, map[string]interface{}{}, tnu) + ts := TestBuildTenantSite(t, dbSession, tn, site, nil, tnu) type fields struct { dbSession *db.Session diff --git a/rest-api/db/pkg/db/model/testing.go b/rest-api/db/pkg/db/model/testing.go index 7a33f1fc02..235d15f932 100644 --- a/rest-api/db/pkg/db/model/testing.go +++ b/rest-api/db/pkg/db/model/testing.go @@ -222,7 +222,7 @@ func TestBuildSite(t *testing.T, dbSession *db.Session, ip *InfrastructureProvid } // TestBuildTenantSite creates a test Tenant/Site relationship -func TestBuildTenantSite(t *testing.T, dbSession *db.Session, tn *Tenant, st *Site, config map[string]interface{}, user *User) *TenantSite { +func TestBuildTenantSite(t *testing.T, dbSession *db.Session, tn *Tenant, st *Site, config *TenantSiteConfig, user *User) *TenantSite { tsDAO := NewTenantSiteDAO(dbSession) ts, err := tsDAO.Create(context.Background(), nil, TenantSiteCreateInput{ diff --git a/rest-api/db/pkg/migrations/20230412224951_tenant_site.go b/rest-api/db/pkg/migrations/20230412224951_tenant_site.go index dce99e89f3..29eaf62490 100644 --- a/rest-api/db/pkg/migrations/20230412224951_tenant_site.go +++ b/rest-api/db/pkg/migrations/20230412224951_tenant_site.go @@ -46,7 +46,7 @@ func createAndPopulateTenantSiteUpMigrationfunc(ctx context.Context, db *bun.DB) TenantOrg: allocation.Tenant.Org, SiteID: allocation.SiteID, EnableSerialConsole: false, - Config: map[string]interface{}{}, + Config: model.TenantSiteConfig{}, CreatedBy: allocation.CreatedBy, } _, err = tx.NewInsert().Model(&tenantSite).Exec(ctx) diff --git a/rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go b/rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go new file mode 100644 index 0000000000..f6b5245e77 --- /dev/null +++ b/rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/uptrace/bun" +) + +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") + } + + _, err := tx.NewAddColumn().Model((*model.TenantAccount)(nil)).IfNotExists(). + ColumnExpr("config JSONB NOT NULL DEFAULT '{}'::jsonb").Exec(ctx) + handleError(tx, err) + + _, err = tx.Exec(` + UPDATE tenant_account ta + SET config = jsonb_set(COALESCE(ta.config, '{}'::jsonb), '{targetedInstanceCreation}', 'true'::jsonb, true) + FROM tenant t + WHERE ta.tenant_id = t.id + AND ta.status = ? + AND COALESCE(t.config->>'targetedInstanceCreation', 'false') = 'true' + `, model.TenantAccountStatusReady) + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [up migration] Added tenant_account.config and backfilled targetedInstanceCreation from tenant.config") + return nil + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] tenant_account.config") + return nil + }) +} diff --git a/rest-api/openapi/spec.yaml b/rest-api/openapi/spec.yaml index 9fbe85f03d..d6782408bc 100644 --- a/rest-api/openapi/spec.yaml +++ b/rest-api/openapi/spec.yaml @@ -14192,6 +14192,11 @@ components: capabilities: $ref: '#/components/schemas/TenantCapabilities' description: Features that are enabled/disabled for Tenant + deprecations: + type: array + items: + $ref: '#/components/schemas/Deprecation' + description: Deprecation notices for Tenant fields TenantCapabilities: title: TenantCapabilities type: object @@ -14201,9 +14206,9 @@ components: properties: targetedInstanceCreation: type: boolean - format: uuid - description: Indicates whether Tenant can create Instances by specifying Machine ID + deprecated: true readOnly: true + description: Deprecated in favor of TenantAccount.siteCapabilities. Indicates whether Tenant can create Instances by specifying Machine ID. TenantStats: title: TenantStats type: object @@ -14443,6 +14448,35 @@ components: description: 'Date/time when the Tenant Account was last updated' format: date-time readOnly: true + siteCapabilities: + type: array + description: Provider-scoped TargetedInstanceCreation settings for this Tenant Account + items: + $ref: '#/components/schemas/TenantAccountSiteCapability' + TenantAccountSiteCapabilityScope: + title: TenantAccountSiteCapabilityScope + type: string + enum: + - global + - limited + TenantAccountSiteCapability: + title: TenantAccountSiteCapability + type: object + required: + - scope + - targetedInstanceCreation + properties: + scope: + $ref: '#/components/schemas/TenantAccountSiteCapabilityScope' + siteIds: + type: array + items: + type: string + format: uuid + description: Required when scope is limited; must be omitted or empty when scope is global + targetedInstanceCreation: + type: boolean + description: Whether TargetedInstanceCreation is enabled for the scope TenantAccountStatus: title: TenantAccountStatus type: string @@ -14481,9 +14515,21 @@ components: description: |- Request data to update a TenantAccount. - No params needed, an empty request will suffice. + Provider Admins may replace `siteCapabilities` only. + Tenant Admins may accept an invite via `tenantContactId` only. + Requests containing both fields are rejected with 400. examples: - - {} + - tenantContactId: 497f6eca-6276-4993-bfeb-53cbbbba6f08 + properties: + tenantContactId: + type: string + format: uuid + description: Tenant Admin invite acceptance; must match the requesting user + siteCapabilities: + type: array + description: Provider Admin replace payload for TargetedInstanceCreation scoping + items: + $ref: '#/components/schemas/TenantAccountSiteCapability' Site: title: Site type: object diff --git a/rest-api/sdk/standard/api_tenant_account.go b/rest-api/sdk/standard/api_tenant_account.go index 8852a781da..c16bf3c752 100644 --- a/rest-api/sdk/standard/api_tenant_account.go +++ b/rest-api/sdk/standard/api_tenant_account.go @@ -628,16 +628,16 @@ func (a *TenantAccountAPIService) GetTenantAccountExecute(r ApiGetTenantAccountR } type ApiUpdateTenantAccountRequest struct { - ctx context.Context - ApiService *TenantAccountAPIService - org string - accountId string - body *map[string]interface{} + ctx context.Context + ApiService *TenantAccountAPIService + org string + accountId string + tenantAccountUpdateRequest *TenantAccountUpdateRequest } // No parameters are required; an empty request body is sufficient. -func (r ApiUpdateTenantAccountRequest) Body(body map[string]interface{}) ApiUpdateTenantAccountRequest { - r.body = &body +func (r ApiUpdateTenantAccountRequest) TenantAccountUpdateRequest(tenantAccountUpdateRequest TenantAccountUpdateRequest) ApiUpdateTenantAccountRequest { + r.tenantAccountUpdateRequest = &tenantAccountUpdateRequest return r } @@ -710,7 +710,7 @@ func (a *TenantAccountAPIService) UpdateTenantAccountExecute(r ApiUpdateTenantAc localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept } // body params - localVarPostBody = r.body + localVarPostBody = r.tenantAccountUpdateRequest req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) if err != nil { return localVarReturnValue, nil, err diff --git a/rest-api/sdk/standard/client.go b/rest-api/sdk/standard/client.go index 2dae2e1ca1..f2d6d74ece 100644 --- a/rest-api/sdk/standard/client.go +++ b/rest-api/sdk/standard/client.go @@ -617,7 +617,10 @@ func addFile(w *multipart.Writer, fieldName, path string) error { if err != nil { return err } - defer file.Close() + err = file.Close() + if err != nil { + return err + } part, err := w.CreateFormFile(fieldName, filepath.Base(path)) if err != nil { diff --git a/rest-api/sdk/standard/model_batch_instance_create_request.go b/rest-api/sdk/standard/model_batch_instance_create_request.go index ff9da71174..0ce8ab13b3 100644 --- a/rest-api/sdk/standard/model_batch_instance_create_request.go +++ b/rest-api/sdk/standard/model_batch_instance_create_request.go @@ -49,9 +49,8 @@ type BatchInstanceCreateRequest struct { // When set to true, the iPXE script specified by OS or overridden here will always be run when rebooting the Instances. OS must be of iPXE type. AlwaysBootWithCustomIpxe *bool `json:"alwaysBootWithCustomIpxe,omitempty"` // When set to true, the Instances will be enabled with the Phone Home service. - PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` - // Key-value objects to be applied to all instances (shared across all instances) - Labels map[string]string `json:"labels,omitempty"` + PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Interface configuration shared across all instances. At least one interface must be specified unless `autoNetwork` is true. Either Subnet or VPC Prefix interfaces allowed, only one of the Subnets or VPC Prefixes can be attached over Physical interface. Interface `ipAddress` is not supported for batch instance creation requests. Mutually exclusive with `autoNetwork`: when `autoNetwork` is true this list MUST be empty. Interfaces []InterfaceCreateRequest `json:"interfaces,omitempty"` // When true, asks NICo to auto-resolve each Instance's network interfaces from the host's underlay (HostInband) network segments. Intended for instances on zero-DPU hosts (or hosts with their DPU in NIC mode). When true: (1) the target VPC's `networkVirtualizationType` MUST be `FLAT`, (2) `interfaces` MUST be empty or omitted, and (3) `secondaryVpcIds` MUST be empty or omitted. diff --git a/rest-api/sdk/standard/model_expected_machine.go b/rest-api/sdk/standard/model_expected_machine.go index 7292b71ebf..e325216a8e 100644 --- a/rest-api/sdk/standard/model_expected_machine.go +++ b/rest-api/sdk/standard/model_expected_machine.go @@ -60,9 +60,8 @@ type ExpectedMachine struct { // Host ID within the tray HostId NullableInt32 `json:"hostId,omitempty"` // When true, this host is eligible for DPF-based provisioning. - IsDpfEnabled *bool `json:"isDpfEnabled,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Machines - Labels map[string]string `json:"labels,omitempty"` + IsDpfEnabled *bool `json:"isDpfEnabled,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Optional per-host lifecycle profile HostLifecycleProfile *HostLifecycleProfile `json:"hostLifecycleProfile,omitempty"` // ISO 8601 datetime when the Expected Machine was created diff --git a/rest-api/sdk/standard/model_expected_machine_create_request.go b/rest-api/sdk/standard/model_expected_machine_create_request.go index 1202619585..04303688a0 100644 --- a/rest-api/sdk/standard/model_expected_machine_create_request.go +++ b/rest-api/sdk/standard/model_expected_machine_create_request.go @@ -57,9 +57,8 @@ type ExpectedMachineCreateRequest struct { // Host ID within the tray HostId NullableInt32 `json:"hostId,omitempty"` // When true, this host is eligible for DPF-based provisioning. - IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Machines - Labels map[string]string `json:"labels,omitempty"` + IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Optional per-host lifecycle profile HostLifecycleProfile *HostLifecycleProfile `json:"hostLifecycleProfile,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_machine_update_request.go b/rest-api/sdk/standard/model_expected_machine_update_request.go index f4a81c3b78..cc5cb3c1fb 100644 --- a/rest-api/sdk/standard/model_expected_machine_update_request.go +++ b/rest-api/sdk/standard/model_expected_machine_update_request.go @@ -55,9 +55,8 @@ type ExpectedMachineUpdateRequest struct { // Host ID within the tray HostId NullableInt32 `json:"hostId,omitempty"` // When true, this host is eligible for DPF-based provisioning. - IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Machines - Labels map[string]string `json:"labels,omitempty"` + IsDpfEnabled NullableBool `json:"isDpfEnabled,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Optional per-host lifecycle profile HostLifecycleProfile *HostLifecycleProfile `json:"hostLifecycleProfile,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_power_shelf.go b/rest-api/sdk/standard/model_expected_power_shelf.go index 20381035cf..a1c35ef6b9 100644 --- a/rest-api/sdk/standard/model_expected_power_shelf.go +++ b/rest-api/sdk/standard/model_expected_power_shelf.go @@ -48,8 +48,7 @@ type ExpectedPowerShelf struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Power Shelves + HostId NullableInt32 `json:"hostId,omitempty"` Labels map[string]string `json:"labels,omitempty"` // ISO 8601 datetime when the Expected Power Shelf was created Created *time.Time `json:"created,omitempty"` diff --git a/rest-api/sdk/standard/model_expected_power_shelf_create_request.go b/rest-api/sdk/standard/model_expected_power_shelf_create_request.go index db0d46edbc..62b6978e03 100644 --- a/rest-api/sdk/standard/model_expected_power_shelf_create_request.go +++ b/rest-api/sdk/standard/model_expected_power_shelf_create_request.go @@ -51,8 +51,7 @@ type ExpectedPowerShelfCreateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Power Shelves + HostId NullableInt32 `json:"hostId,omitempty"` Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_power_shelf_update_request.go b/rest-api/sdk/standard/model_expected_power_shelf_update_request.go index d666917993..925406a9ae 100644 --- a/rest-api/sdk/standard/model_expected_power_shelf_update_request.go +++ b/rest-api/sdk/standard/model_expected_power_shelf_update_request.go @@ -49,8 +49,7 @@ type ExpectedPowerShelfUpdateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Power Shelves + HostId NullableInt32 `json:"hostId,omitempty"` Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_rack.go b/rest-api/sdk/standard/model_expected_rack.go index ff37f93e9d..91a5368905 100644 --- a/rest-api/sdk/standard/model_expected_rack.go +++ b/rest-api/sdk/standard/model_expected_rack.go @@ -34,9 +34,8 @@ type ExpectedRack struct { // Human-readable name of the Expected Rack Name *string `json:"name,omitempty"` // Human-readable description of the Expected Rack - Description *string `json:"description,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Racks. Well-known keys (`chassis.*`, `location.*`) are used to convey chassis identity and physical location. - Labels map[string]string `json:"labels,omitempty"` + Description *string `json:"description,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // ISO 8601 datetime when the Expected Rack was created Created *time.Time `json:"created,omitempty"` // ISO 8601 datetime when the Expected Rack was last updated diff --git a/rest-api/sdk/standard/model_expected_rack_create_request.go b/rest-api/sdk/standard/model_expected_rack_create_request.go index bd83ee8235..15e210dc7c 100644 --- a/rest-api/sdk/standard/model_expected_rack_create_request.go +++ b/rest-api/sdk/standard/model_expected_rack_create_request.go @@ -33,9 +33,8 @@ type ExpectedRackCreateRequest struct { // Human-readable name of the Expected Rack Name NullableString `json:"name,omitempty"` // Human-readable description of the Expected Rack - Description NullableString `json:"description,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Racks. Well-known keys (`chassis.*`, `location.*`) are used to convey chassis identity and physical location. - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } type _ExpectedRackCreateRequest ExpectedRackCreateRequest diff --git a/rest-api/sdk/standard/model_expected_rack_update_request.go b/rest-api/sdk/standard/model_expected_rack_update_request.go index dfdf947bff..f6c144a6c3 100644 --- a/rest-api/sdk/standard/model_expected_rack_update_request.go +++ b/rest-api/sdk/standard/model_expected_rack_update_request.go @@ -31,9 +31,8 @@ type ExpectedRackUpdateRequest struct { // Human-readable name of the Expected Rack Name NullableString `json:"name,omitempty"` // Human-readable description of the Expected Rack - Description NullableString `json:"description,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Racks. Well-known keys (`chassis.*`, `location.*`) are used to convey chassis identity and physical location. - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } // NewExpectedRackUpdateRequest instantiates a new ExpectedRackUpdateRequest object diff --git a/rest-api/sdk/standard/model_expected_switch.go b/rest-api/sdk/standard/model_expected_switch.go index 070edd9c08..2c50020107 100644 --- a/rest-api/sdk/standard/model_expected_switch.go +++ b/rest-api/sdk/standard/model_expected_switch.go @@ -50,8 +50,7 @@ type ExpectedSwitch struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Switches + HostId NullableInt32 `json:"hostId,omitempty"` Labels map[string]string `json:"labels,omitempty"` // ISO 8601 datetime when the Expected Switch was created Created *time.Time `json:"created,omitempty"` diff --git a/rest-api/sdk/standard/model_expected_switch_create_request.go b/rest-api/sdk/standard/model_expected_switch_create_request.go index c690a3a951..59b1395a0e 100644 --- a/rest-api/sdk/standard/model_expected_switch_create_request.go +++ b/rest-api/sdk/standard/model_expected_switch_create_request.go @@ -57,8 +57,7 @@ type ExpectedSwitchCreateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Switches + HostId NullableInt32 `json:"hostId,omitempty"` Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_expected_switch_update_request.go b/rest-api/sdk/standard/model_expected_switch_update_request.go index b049948493..79a0ca757b 100644 --- a/rest-api/sdk/standard/model_expected_switch_update_request.go +++ b/rest-api/sdk/standard/model_expected_switch_update_request.go @@ -55,8 +55,7 @@ type ExpectedSwitchUpdateRequest struct { // Tray index within the rack TrayIdx NullableInt32 `json:"trayIdx,omitempty"` // Host ID within the tray - HostId NullableInt32 `json:"hostId,omitempty"` - // User-defined key-value pairs for organizing and categorizing Expected Switches + HostId NullableInt32 `json:"hostId,omitempty"` Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_infini_band_partition.go b/rest-api/sdk/standard/model_infini_band_partition.go index d0a2067786..614d969a62 100644 --- a/rest-api/sdk/standard/model_infini_band_partition.go +++ b/rest-api/sdk/standard/model_infini_band_partition.go @@ -46,9 +46,8 @@ type InfiniBandPartition struct { // MTU configured for the InfiniBand Partition Mtu NullableInt32 `json:"mtu,omitempty"` // Whether SHARP is enabled for the InfiniBand Partition - EnableSharp *bool `json:"enableSharp,omitempty"` - // String key-value pairs describing InfiniBand Partition labels. Up to 10 key-value pairs can be specified - Labels map[string]string `json:"labels,omitempty"` + EnableSharp *bool `json:"enableSharp,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Status of the InfiniBand Partition Status *InfiniBandPartitionStatus `json:"status,omitempty"` // Chronological status history for the InfiniBand Partition diff --git a/rest-api/sdk/standard/model_infini_band_partition_create_request.go b/rest-api/sdk/standard/model_infini_band_partition_create_request.go index 72ad503a2c..fcd9563916 100644 --- a/rest-api/sdk/standard/model_infini_band_partition_create_request.go +++ b/rest-api/sdk/standard/model_infini_band_partition_create_request.go @@ -29,8 +29,7 @@ type InfiniBandPartitionCreateRequest struct { // Optional description of the Partition Description NullableString `json:"description,omitempty"` // ID of the Site the Partition should belong to - SiteId string `json:"siteId"` - // String key-value pairs describing Partition labels. Up to 10 key-value pairs can be specified + SiteId string `json:"siteId"` Labels map[string]string `json:"labels,omitempty"` } diff --git a/rest-api/sdk/standard/model_infini_band_partition_update_request.go b/rest-api/sdk/standard/model_infini_band_partition_update_request.go index ff49895a42..fe7fe49ad6 100644 --- a/rest-api/sdk/standard/model_infini_band_partition_update_request.go +++ b/rest-api/sdk/standard/model_infini_band_partition_update_request.go @@ -27,9 +27,8 @@ type InfiniBandPartitionUpdateRequest struct { // Name of the InfiniBand Partition Name string `json:"name"` // Description of the InfiniBand Partition - Description NullableString `json:"description,omitempty"` - // String key-value pairs describing Partition labels. Up to 10 key-value pairs can be specified - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } type _InfiniBandPartitionUpdateRequest InfiniBandPartitionUpdateRequest diff --git a/rest-api/sdk/standard/model_instance.go b/rest-api/sdk/standard/model_instance.go index cf056311bb..8dcbcda10a 100644 --- a/rest-api/sdk/standard/model_instance.go +++ b/rest-api/sdk/standard/model_instance.go @@ -60,9 +60,8 @@ type Instance struct { // Indicates whether the Phone Home service should be enabled or disabled for the Instance PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` // UserData is inherited from Operating System or specified by user if allowed - UserData NullableString `json:"userData,omitempty"` - // User-specified Instance labels - Labels map[string]string `json:"labels,omitempty"` + UserData NullableString `json:"userData,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Indicates whether an update is available for the Instance. Updates can be applied on reboot IsUpdatePending *bool `json:"isUpdatePending,omitempty"` // Serial Console URL for the Instance. Format: ssh://@siteSerialConsoleHostname diff --git a/rest-api/sdk/standard/model_instance_create_request.go b/rest-api/sdk/standard/model_instance_create_request.go index cb93101fa6..60bb43e471 100644 --- a/rest-api/sdk/standard/model_instance_create_request.go +++ b/rest-api/sdk/standard/model_instance_create_request.go @@ -49,9 +49,8 @@ type InstanceCreateRequest struct { // When set to true, the iPXE script specified by OS or overridden here will always be run when rebooting the Instance. OS must be of iPXE type. AlwaysBootWithCustomIpxe *bool `json:"alwaysBootWithCustomIpxe,omitempty"` // When set to true, the Instance will be enabled with the Phone Home service. - PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` - // User-defined key-value labels - Labels map[string]string `json:"labels,omitempty"` + PhoneHomeEnabled *bool `json:"phoneHomeEnabled,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // At least one interface must be specified unless `autoNetwork` is true. Either Subnet or VPC Prefix interfaces allowed. Only one of the Subnets or VPC Prefixes can be attached over Physical interface. If only one Subnet is specified, then it will be attached over physical interface regardless of the value of isPhysical. In case of VPC Prefix, isPhysical will always be true. Mutually exclusive with `autoNetwork`: when `autoNetwork` is true this list MUST be empty. Interfaces []InterfaceCreateRequest `json:"interfaces,omitempty"` // When true, asks NICo to auto-resolve the Instance's network interfaces from the host's underlay (HostInband) network segments. Intended for instances on zero-DPU hosts (or hosts with their DPU in NIC mode). When true: (1) the target VPC's `networkVirtualizationType` MUST be `FLAT`, (2) `interfaces` MUST be empty or omitted, and (3) `secondaryVpcIds` MUST be empty or omitted. Resolved interfaces surface on the Instance's read response. diff --git a/rest-api/sdk/standard/model_instance_type.go b/rest-api/sdk/standard/model_instance_type.go index 13efdb2e95..697e9baa2c 100644 --- a/rest-api/sdk/standard/model_instance_type.go +++ b/rest-api/sdk/standard/model_instance_type.go @@ -34,8 +34,7 @@ type InstanceType struct { // ID of the Infrastructure Provider that owns the Instance Type InfrastructureProviderId *string `json:"infrastructureProviderId,omitempty"` // ID of the Site that owns the Instance Type - SiteId *string `json:"siteId,omitempty"` - // User-defined key-value labels for the Instance Type + SiteId *string `json:"siteId,omitempty"` Labels map[string]string `json:"labels,omitempty"` // List of capabilities that are supported by the Machine's of this Instance Type MachineCapabilities []MachineCapability `json:"machineCapabilities,omitempty"` diff --git a/rest-api/sdk/standard/model_instance_type_create_request.go b/rest-api/sdk/standard/model_instance_type_create_request.go index b8395d97c2..412c36c504 100644 --- a/rest-api/sdk/standard/model_instance_type_create_request.go +++ b/rest-api/sdk/standard/model_instance_type_create_request.go @@ -29,8 +29,7 @@ type InstanceTypeCreateRequest struct { // Description of the Instance Type Description NullableString `json:"description,omitempty"` // ID of the site - SiteId string `json:"siteId"` - // User-defined key-value labels for the Instance Type + SiteId string `json:"siteId"` Labels map[string]string `json:"labels,omitempty"` // Site Controller assigned Machine type ControllerMachineType NullableString `json:"controllerMachineType,omitempty"` diff --git a/rest-api/sdk/standard/model_instance_type_update_request.go b/rest-api/sdk/standard/model_instance_type_update_request.go index ee2c4e61ae..9215552e85 100644 --- a/rest-api/sdk/standard/model_instance_type_update_request.go +++ b/rest-api/sdk/standard/model_instance_type_update_request.go @@ -25,9 +25,8 @@ type InstanceTypeUpdateRequest struct { // Name of the Instance Type Name NullableString `json:"name,omitempty"` // Description of the Instance Type - Description NullableString `json:"description,omitempty"` - // User-defined key-value labels for the Instance Type - Labels map[string]string `json:"labels,omitempty"` + Description NullableString `json:"description,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // List of Machine Capabilities to match MachineCapabilities []MachineCapability `json:"machineCapabilities,omitempty"` } diff --git a/rest-api/sdk/standard/model_instance_update_request.go b/rest-api/sdk/standard/model_instance_update_request.go index 534a414eca..493f4075fc 100644 --- a/rest-api/sdk/standard/model_instance_update_request.go +++ b/rest-api/sdk/standard/model_instance_update_request.go @@ -45,9 +45,8 @@ type InstanceUpdateRequest struct { // Whether the custom iPXE data should be used for every boot. AlwaysBootWithCustomIpxe NullableBool `json:"alwaysBootWithCustomIpxe,omitempty"` // Indicates whether the Phone Home service should be enabled or disabled for the Instance - PhoneHomeEnabled NullableBool `json:"phoneHomeEnabled,omitempty"` - // Update labels of the Instance. The labels will be replaced with the labels sent in the request. Any labels not included in the request will be removed. To retain existing labels, fetch them first and include them in this request. - Labels map[string]string `json:"labels,omitempty"` + PhoneHomeEnabled NullableBool `json:"phoneHomeEnabled,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // IDs of additional VPCs the Instance should attach to through non-primary interfaces. This field may only be specified when every entry in `interfaces` uses `vpcPrefixId`. IDs must be unique, must be valid UUIDs, and must not include the primary `vpcId`. SecondaryVpcIds []string `json:"secondaryVpcIds,omitempty"` // Update Interfaces of the Instance. Mutually exclusive with `autoNetwork`: when `autoNetwork` is true this list MUST be empty. diff --git a/rest-api/sdk/standard/model_interface.go b/rest-api/sdk/standard/model_interface.go index 1f9fc2f912..d891621494 100644 --- a/rest-api/sdk/standard/model_interface.go +++ b/rest-api/sdk/standard/model_interface.go @@ -44,8 +44,7 @@ type Interface struct { // A list of IPv4 or IPv6 addresses IpAddresses []string `json:"ipAddresses,omitempty"` // Explicitly requested IP address for the interface. This is only used for VPC Prefix-based interfaces and is not valid for Subnet-based interfaces. The least-significant host bit must be 1. - RequestedIpAddress NullableString `json:"requestedIpAddress,omitempty"` - // Inline interface-local routing profile options. Only valid for VPC Prefix-based interfaces. + RequestedIpAddress NullableString `json:"requestedIpAddress,omitempty"` InlineRoutingProfile NullableInterfaceInlineRoutingProfile `json:"inlineRoutingProfile,omitempty"` // Status of the Interface Status *InterfaceStatus `json:"status,omitempty"` diff --git a/rest-api/sdk/standard/model_interface_create_request.go b/rest-api/sdk/standard/model_interface_create_request.go index 26d7105a79..48f0bdc287 100644 --- a/rest-api/sdk/standard/model_interface_create_request.go +++ b/rest-api/sdk/standard/model_interface_create_request.go @@ -27,8 +27,7 @@ type InterfaceCreateRequest struct { // ID of the VPC Prefix to attach to the Interface VpcPrefixId *string `json:"vpcPrefixId,omitempty"` // Explicitly requested IP address for the interface. It cannot be specified for Subnet-based interfaces. The least-significant host bit must be 1. - IpAddress NullableString `json:"ipAddress,omitempty"` - // Inline interface-local routing profile options. It cannot be specified for Subnet-based interfaces. + IpAddress NullableString `json:"ipAddress,omitempty"` InlineRoutingProfile NullableInterfaceInlineRoutingProfile `json:"inlineRoutingProfile,omitempty"` // Specifies whether this Subnet or VPC Prefix should be attached to the Instance over physical interface. IsPhysical *bool `json:"isPhysical,omitempty"` diff --git a/rest-api/sdk/standard/model_machine.go b/rest-api/sdk/standard/model_machine.go index 21eaf68093..53b855ea0a 100644 --- a/rest-api/sdk/standard/model_machine.go +++ b/rest-api/sdk/standard/model_machine.go @@ -60,9 +60,8 @@ type Machine struct { // Health information about the machine Health *MachineHealth `json:"health,omitempty"` // Only available to Providers. Returned if the `includeMetadata` query parameter is specified. Otherwise attribute is omitted from response. - Metadata *MachineMetadata `json:"metadata,omitempty"` - // User-specified Machine labels - Labels map[string]string `json:"labels,omitempty"` + Metadata *MachineMetadata `json:"metadata,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Status represents the status of the machine Status *MachineStatus `json:"status,omitempty"` // Indicates whether the machine is usable by or currently in use by a tenant. diff --git a/rest-api/sdk/standard/model_machine_update_request.go b/rest-api/sdk/standard/model_machine_update_request.go index 7e006cbf70..e9307655a6 100644 --- a/rest-api/sdk/standard/model_machine_update_request.go +++ b/rest-api/sdk/standard/model_machine_update_request.go @@ -29,9 +29,8 @@ type MachineUpdateRequest struct { // Set to `true` to enable maintenance mode and to `false` to disable maintenance mode. Can be set by Provider or privileged Tenant. SetMaintenanceMode NullableBool `json:"setMaintenanceMode,omitempty"` // Optional message describing the reason for moving Machine into maintenance mode. Can be updated by Provider or privileged Tenant. - MaintenanceMessage NullableString `json:"maintenanceMessage,omitempty"` - // Machine labels will be overwritten, include existing labels to preserve them. Can be updated by Provider or privileged Tenant. - Labels map[string]string `json:"labels,omitempty"` + MaintenanceMessage NullableString `json:"maintenanceMessage,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Request to enter/exit online repair OnlineRepair *MachineOnlineRepair `json:"onlineRepair,omitempty"` // Required when `onlineRepair.enabled` is true. Must not be set when exiting online repair (`onlineRepair.enabled` false). diff --git a/rest-api/sdk/standard/model_network_security_group.go b/rest-api/sdk/standard/model_network_security_group.go index 20c058e869..ec4fb562da 100644 --- a/rest-api/sdk/standard/model_network_security_group.go +++ b/rest-api/sdk/standard/model_network_security_group.go @@ -45,8 +45,7 @@ type NetworkSecurityGroup struct { RuleCount *int32 `json:"ruleCount,omitempty"` // Attachment statistics for the Network Security Group. Returned when the `includeAttachmentStats` query parameter is set to true in retrieval endpoints. AttachmentStats *NetworkSecurityGroupStats `json:"attachmentStats,omitempty"` - // Set of labels/tags for the Network Security Group - Labels map[string]string `json:"labels,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Date/time when the Network Security Group was created Created *time.Time `json:"created,omitempty"` // Date/time when the Network Security Group was last updated diff --git a/rest-api/sdk/standard/model_network_security_group_create_request.go b/rest-api/sdk/standard/model_network_security_group_create_request.go index e29da1ca45..e4f43a8be8 100644 --- a/rest-api/sdk/standard/model_network_security_group_create_request.go +++ b/rest-api/sdk/standard/model_network_security_group_create_request.go @@ -35,9 +35,8 @@ type NetworkSecurityGroupCreateRequest struct { // Egress rules with protocol and destination ports defined but without source ports defined should automatically be made stateful. StatefulEgress *bool `json:"statefulEgress,omitempty"` // Rules that belong to the Network Security Group - Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` - // User-defined key-value labels for the Network Security Group - Labels map[string]string `json:"labels,omitempty"` + Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } type _NetworkSecurityGroupCreateRequest NetworkSecurityGroupCreateRequest diff --git a/rest-api/sdk/standard/model_network_security_group_update_request.go b/rest-api/sdk/standard/model_network_security_group_update_request.go index 78f807ba6c..2e3ef05bc9 100644 --- a/rest-api/sdk/standard/model_network_security_group_update_request.go +++ b/rest-api/sdk/standard/model_network_security_group_update_request.go @@ -29,9 +29,8 @@ type NetworkSecurityGroupUpdateRequest struct { // Egress rules with protocol and destination ports defined but without source ports defined should automatically be made stateful. StatefulEgress *bool `json:"statefulEgress,omitempty"` // Update rules of the Network Security Group. The rules will be replaced with the rules sent in the request. Any rules not included in the request will be removed. To retain existing rules, fetch them first and include them. - Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` - // User-defined key-value labels for the Network Security Group - Labels map[string]string `json:"labels,omitempty"` + Rules []NetworkSecurityGroupRule `json:"rules,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } // NewNetworkSecurityGroupUpdateRequest instantiates a new NetworkSecurityGroupUpdateRequest object diff --git a/rest-api/sdk/standard/model_tenant.go b/rest-api/sdk/standard/model_tenant.go index 154d84c76f..e5b80145fb 100644 --- a/rest-api/sdk/standard/model_tenant.go +++ b/rest-api/sdk/standard/model_tenant.go @@ -35,6 +35,8 @@ type Tenant struct { Updated *time.Time `json:"updated,omitempty"` // Features that are enabled/disabled for Tenant Capabilities *TenantCapabilities `json:"capabilities,omitempty"` + // Deprecation notices for Tenant fields + Deprecations []Deprecation `json:"deprecations,omitempty"` } // NewTenant instantiates a new Tenant object @@ -257,6 +259,38 @@ func (o *Tenant) SetCapabilities(v TenantCapabilities) { o.Capabilities = &v } +// GetDeprecations returns the Deprecations field value if set, zero value otherwise. +func (o *Tenant) GetDeprecations() []Deprecation { + if o == nil || IsNil(o.Deprecations) { + var ret []Deprecation + return ret + } + return o.Deprecations +} + +// GetDeprecationsOk returns a tuple with the Deprecations field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Tenant) GetDeprecationsOk() ([]Deprecation, bool) { + if o == nil || IsNil(o.Deprecations) { + return nil, false + } + return o.Deprecations, true +} + +// HasDeprecations returns a boolean if a field has been set. +func (o *Tenant) HasDeprecations() bool { + if o != nil && !IsNil(o.Deprecations) { + return true + } + + return false +} + +// SetDeprecations gets a reference to the given []Deprecation and assigns it to the Deprecations field. +func (o *Tenant) SetDeprecations(v []Deprecation) { + o.Deprecations = v +} + func (o Tenant) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -285,6 +319,9 @@ func (o Tenant) ToMap() (map[string]interface{}, error) { if !IsNil(o.Capabilities) { toSerialize["capabilities"] = o.Capabilities } + if !IsNil(o.Deprecations) { + toSerialize["deprecations"] = o.Deprecations + } return toSerialize, nil } diff --git a/rest-api/sdk/standard/model_tenant_account.go b/rest-api/sdk/standard/model_tenant_account.go index 6e88e6ce3a..97e6431612 100644 --- a/rest-api/sdk/standard/model_tenant_account.go +++ b/rest-api/sdk/standard/model_tenant_account.go @@ -47,6 +47,8 @@ type TenantAccount struct { Created *time.Time `json:"created,omitempty"` // Date/time when the Tenant Account was last updated Updated *time.Time `json:"updated,omitempty"` + // Provider-scoped TargetedInstanceCreation settings for this Tenant Account + SiteCapabilities []TenantAccountSiteCapability `json:"siteCapabilities,omitempty"` } // NewTenantAccount instantiates a new TenantAccount object @@ -472,6 +474,38 @@ func (o *TenantAccount) SetUpdated(v time.Time) { o.Updated = &v } +// GetSiteCapabilities returns the SiteCapabilities field value if set, zero value otherwise. +func (o *TenantAccount) GetSiteCapabilities() []TenantAccountSiteCapability { + if o == nil || IsNil(o.SiteCapabilities) { + var ret []TenantAccountSiteCapability + return ret + } + return o.SiteCapabilities +} + +// GetSiteCapabilitiesOk returns a tuple with the SiteCapabilities field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantAccount) GetSiteCapabilitiesOk() ([]TenantAccountSiteCapability, bool) { + if o == nil || IsNil(o.SiteCapabilities) { + return nil, false + } + return o.SiteCapabilities, true +} + +// HasSiteCapabilities returns a boolean if a field has been set. +func (o *TenantAccount) HasSiteCapabilities() bool { + if o != nil && !IsNil(o.SiteCapabilities) { + return true + } + + return false +} + +// SetSiteCapabilities gets a reference to the given []TenantAccountSiteCapability and assigns it to the SiteCapabilities field. +func (o *TenantAccount) SetSiteCapabilities(v []TenantAccountSiteCapability) { + o.SiteCapabilities = v +} + func (o TenantAccount) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -518,6 +552,9 @@ func (o TenantAccount) ToMap() (map[string]interface{}, error) { if !IsNil(o.Updated) { toSerialize["updated"] = o.Updated } + if !IsNil(o.SiteCapabilities) { + toSerialize["siteCapabilities"] = o.SiteCapabilities + } return toSerialize, nil } diff --git a/rest-api/sdk/standard/model_tenant_account_site_capability.go b/rest-api/sdk/standard/model_tenant_account_site_capability.go new file mode 100644 index 0000000000..d985c01a8c --- /dev/null +++ b/rest-api/sdk/standard/model_tenant_account_site_capability.go @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the TenantAccountSiteCapability type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &TenantAccountSiteCapability{} + +// TenantAccountSiteCapability struct for TenantAccountSiteCapability +type TenantAccountSiteCapability struct { + Scope TenantAccountSiteCapabilityScope `json:"scope"` + // Required when scope is limited; must be omitted or empty when scope is global + SiteIds []string `json:"siteIds,omitempty"` + // Whether TargetedInstanceCreation is enabled for the scope + TargetedInstanceCreation bool `json:"targetedInstanceCreation"` +} + +type _TenantAccountSiteCapability TenantAccountSiteCapability + +// NewTenantAccountSiteCapability instantiates a new TenantAccountSiteCapability object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewTenantAccountSiteCapability(scope TenantAccountSiteCapabilityScope, targetedInstanceCreation bool) *TenantAccountSiteCapability { + this := TenantAccountSiteCapability{} + this.Scope = scope + this.TargetedInstanceCreation = targetedInstanceCreation + return &this +} + +// NewTenantAccountSiteCapabilityWithDefaults instantiates a new TenantAccountSiteCapability object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewTenantAccountSiteCapabilityWithDefaults() *TenantAccountSiteCapability { + this := TenantAccountSiteCapability{} + return &this +} + +// GetScope returns the Scope field value +func (o *TenantAccountSiteCapability) GetScope() TenantAccountSiteCapabilityScope { + if o == nil { + var ret TenantAccountSiteCapabilityScope + return ret + } + + return o.Scope +} + +// GetScopeOk returns a tuple with the Scope field value +// and a boolean to check if the value has been set. +func (o *TenantAccountSiteCapability) GetScopeOk() (*TenantAccountSiteCapabilityScope, bool) { + if o == nil { + return nil, false + } + return &o.Scope, true +} + +// SetScope sets field value +func (o *TenantAccountSiteCapability) SetScope(v TenantAccountSiteCapabilityScope) { + o.Scope = v +} + +// GetSiteIds returns the SiteIds field value if set, zero value otherwise. +func (o *TenantAccountSiteCapability) GetSiteIds() []string { + if o == nil || IsNil(o.SiteIds) { + var ret []string + return ret + } + return o.SiteIds +} + +// GetSiteIdsOk returns a tuple with the SiteIds field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantAccountSiteCapability) GetSiteIdsOk() ([]string, bool) { + if o == nil || IsNil(o.SiteIds) { + return nil, false + } + return o.SiteIds, true +} + +// HasSiteIds returns a boolean if a field has been set. +func (o *TenantAccountSiteCapability) HasSiteIds() bool { + if o != nil && !IsNil(o.SiteIds) { + return true + } + + return false +} + +// SetSiteIds gets a reference to the given []string and assigns it to the SiteIds field. +func (o *TenantAccountSiteCapability) SetSiteIds(v []string) { + o.SiteIds = v +} + +// GetTargetedInstanceCreation returns the TargetedInstanceCreation field value +func (o *TenantAccountSiteCapability) GetTargetedInstanceCreation() bool { + if o == nil { + var ret bool + return ret + } + + return o.TargetedInstanceCreation +} + +// GetTargetedInstanceCreationOk returns a tuple with the TargetedInstanceCreation field value +// and a boolean to check if the value has been set. +func (o *TenantAccountSiteCapability) GetTargetedInstanceCreationOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.TargetedInstanceCreation, true +} + +// SetTargetedInstanceCreation sets field value +func (o *TenantAccountSiteCapability) SetTargetedInstanceCreation(v bool) { + o.TargetedInstanceCreation = v +} + +func (o TenantAccountSiteCapability) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o TenantAccountSiteCapability) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["scope"] = o.Scope + if !IsNil(o.SiteIds) { + toSerialize["siteIds"] = o.SiteIds + } + toSerialize["targetedInstanceCreation"] = o.TargetedInstanceCreation + return toSerialize, nil +} + +func (o *TenantAccountSiteCapability) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "scope", + "targetedInstanceCreation", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if value, exists := allProperties[requiredProperty]; !exists || value == nil { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varTenantAccountSiteCapability := _TenantAccountSiteCapability{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varTenantAccountSiteCapability) + + if err != nil { + return err + } + + *o = TenantAccountSiteCapability(varTenantAccountSiteCapability) + + return err +} + +type NullableTenantAccountSiteCapability struct { + value *TenantAccountSiteCapability + isSet bool +} + +func (v NullableTenantAccountSiteCapability) Get() *TenantAccountSiteCapability { + return v.value +} + +func (v *NullableTenantAccountSiteCapability) Set(val *TenantAccountSiteCapability) { + v.value = val + v.isSet = true +} + +func (v NullableTenantAccountSiteCapability) IsSet() bool { + return v.isSet +} + +func (v *NullableTenantAccountSiteCapability) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableTenantAccountSiteCapability(val *TenantAccountSiteCapability) *NullableTenantAccountSiteCapability { + return &NullableTenantAccountSiteCapability{value: val, isSet: true} +} + +func (v NullableTenantAccountSiteCapability) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableTenantAccountSiteCapability) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_tenant_account_site_capability_scope.go b/rest-api/sdk/standard/model_tenant_account_site_capability_scope.go new file mode 100644 index 0000000000..35c6c99396 --- /dev/null +++ b/rest-api/sdk/standard/model_tenant_account_site_capability_scope.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" + "fmt" +) + +// TenantAccountSiteCapabilityScope the model 'TenantAccountSiteCapabilityScope' +type TenantAccountSiteCapabilityScope string + +// List of TenantAccountSiteCapabilityScope +const ( + TENANTACCOUNTSITECAPABILITYSCOPE_GLOBAL TenantAccountSiteCapabilityScope = "global" + TENANTACCOUNTSITECAPABILITYSCOPE_LIMITED TenantAccountSiteCapabilityScope = "limited" +) + +// All allowed values of TenantAccountSiteCapabilityScope enum +var AllowedTenantAccountSiteCapabilityScopeEnumValues = []TenantAccountSiteCapabilityScope{ + "global", + "limited", +} + +func (v *TenantAccountSiteCapabilityScope) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := TenantAccountSiteCapabilityScope(value) + for _, existing := range AllowedTenantAccountSiteCapabilityScopeEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid TenantAccountSiteCapabilityScope", value) +} + +// NewTenantAccountSiteCapabilityScopeFromValue returns a pointer to a valid TenantAccountSiteCapabilityScope +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewTenantAccountSiteCapabilityScopeFromValue(v string) (*TenantAccountSiteCapabilityScope, error) { + ev := TenantAccountSiteCapabilityScope(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for TenantAccountSiteCapabilityScope: valid values are %v", v, AllowedTenantAccountSiteCapabilityScopeEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v TenantAccountSiteCapabilityScope) IsValid() bool { + for _, existing := range AllowedTenantAccountSiteCapabilityScopeEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to TenantAccountSiteCapabilityScope value +func (v TenantAccountSiteCapabilityScope) Ptr() *TenantAccountSiteCapabilityScope { + return &v +} + +type NullableTenantAccountSiteCapabilityScope struct { + value *TenantAccountSiteCapabilityScope + isSet bool +} + +func (v NullableTenantAccountSiteCapabilityScope) Get() *TenantAccountSiteCapabilityScope { + return v.value +} + +func (v *NullableTenantAccountSiteCapabilityScope) Set(val *TenantAccountSiteCapabilityScope) { + v.value = val + v.isSet = true +} + +func (v NullableTenantAccountSiteCapabilityScope) IsSet() bool { + return v.isSet +} + +func (v *NullableTenantAccountSiteCapabilityScope) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableTenantAccountSiteCapabilityScope(val *TenantAccountSiteCapabilityScope) *NullableTenantAccountSiteCapabilityScope { + return &NullableTenantAccountSiteCapabilityScope{value: val, isSet: true} +} + +func (v NullableTenantAccountSiteCapabilityScope) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableTenantAccountSiteCapabilityScope) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_tenant_account_update_request.go b/rest-api/sdk/standard/model_tenant_account_update_request.go new file mode 100644 index 0000000000..4454be7bc7 --- /dev/null +++ b/rest-api/sdk/standard/model_tenant_account_update_request.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the TenantAccountUpdateRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &TenantAccountUpdateRequest{} + +// TenantAccountUpdateRequest Request data to update a TenantAccount. Provider Admins may replace `siteCapabilities` only. Tenant Admins may accept an invite via `tenantContactId` only. Requests containing both fields are rejected with 400. +type TenantAccountUpdateRequest struct { + // Tenant Admin invite acceptance; must match the requesting user + TenantContactId *string `json:"tenantContactId,omitempty"` + // Provider Admin replace payload for TargetedInstanceCreation scoping + SiteCapabilities []TenantAccountSiteCapability `json:"siteCapabilities,omitempty"` +} + +// NewTenantAccountUpdateRequest instantiates a new TenantAccountUpdateRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewTenantAccountUpdateRequest() *TenantAccountUpdateRequest { + this := TenantAccountUpdateRequest{} + return &this +} + +// NewTenantAccountUpdateRequestWithDefaults instantiates a new TenantAccountUpdateRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewTenantAccountUpdateRequestWithDefaults() *TenantAccountUpdateRequest { + this := TenantAccountUpdateRequest{} + return &this +} + +// GetTenantContactId returns the TenantContactId field value if set, zero value otherwise. +func (o *TenantAccountUpdateRequest) GetTenantContactId() string { + if o == nil || IsNil(o.TenantContactId) { + var ret string + return ret + } + return *o.TenantContactId +} + +// GetTenantContactIdOk returns a tuple with the TenantContactId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantAccountUpdateRequest) GetTenantContactIdOk() (*string, bool) { + if o == nil || IsNil(o.TenantContactId) { + return nil, false + } + return o.TenantContactId, true +} + +// HasTenantContactId returns a boolean if a field has been set. +func (o *TenantAccountUpdateRequest) HasTenantContactId() bool { + if o != nil && !IsNil(o.TenantContactId) { + return true + } + + return false +} + +// SetTenantContactId gets a reference to the given string and assigns it to the TenantContactId field. +func (o *TenantAccountUpdateRequest) SetTenantContactId(v string) { + o.TenantContactId = &v +} + +// GetSiteCapabilities returns the SiteCapabilities field value if set, zero value otherwise. +func (o *TenantAccountUpdateRequest) GetSiteCapabilities() []TenantAccountSiteCapability { + if o == nil || IsNil(o.SiteCapabilities) { + var ret []TenantAccountSiteCapability + return ret + } + return o.SiteCapabilities +} + +// GetSiteCapabilitiesOk returns a tuple with the SiteCapabilities field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantAccountUpdateRequest) GetSiteCapabilitiesOk() ([]TenantAccountSiteCapability, bool) { + if o == nil || IsNil(o.SiteCapabilities) { + return nil, false + } + return o.SiteCapabilities, true +} + +// HasSiteCapabilities returns a boolean if a field has been set. +func (o *TenantAccountUpdateRequest) HasSiteCapabilities() bool { + if o != nil && !IsNil(o.SiteCapabilities) { + return true + } + + return false +} + +// SetSiteCapabilities gets a reference to the given []TenantAccountSiteCapability and assigns it to the SiteCapabilities field. +func (o *TenantAccountUpdateRequest) SetSiteCapabilities(v []TenantAccountSiteCapability) { + o.SiteCapabilities = v +} + +func (o TenantAccountUpdateRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o TenantAccountUpdateRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.TenantContactId) { + toSerialize["tenantContactId"] = o.TenantContactId + } + if !IsNil(o.SiteCapabilities) { + toSerialize["siteCapabilities"] = o.SiteCapabilities + } + return toSerialize, nil +} + +type NullableTenantAccountUpdateRequest struct { + value *TenantAccountUpdateRequest + isSet bool +} + +func (v NullableTenantAccountUpdateRequest) Get() *TenantAccountUpdateRequest { + return v.value +} + +func (v *NullableTenantAccountUpdateRequest) Set(val *TenantAccountUpdateRequest) { + v.value = val + v.isSet = true +} + +func (v NullableTenantAccountUpdateRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableTenantAccountUpdateRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableTenantAccountUpdateRequest(val *TenantAccountUpdateRequest) *NullableTenantAccountUpdateRequest { + return &NullableTenantAccountUpdateRequest{value: val, isSet: true} +} + +func (v NullableTenantAccountUpdateRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableTenantAccountUpdateRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_tenant_capabilities.go b/rest-api/sdk/standard/model_tenant_capabilities.go index e3d7386fec..9929d15026 100644 --- a/rest-api/sdk/standard/model_tenant_capabilities.go +++ b/rest-api/sdk/standard/model_tenant_capabilities.go @@ -22,7 +22,8 @@ var _ MappedNullable = &TenantCapabilities{} // TenantCapabilities TenantCapabilities defines the set of features enabled for Tenant type TenantCapabilities struct { - // Indicates whether Tenant can create Instances by specifying Machine ID + // Deprecated in favor of TenantAccount.siteCapabilities. Indicates whether Tenant can create Instances by specifying Machine ID. + // Deprecated TargetedInstanceCreation *bool `json:"targetedInstanceCreation,omitempty"` } @@ -44,6 +45,7 @@ func NewTenantCapabilitiesWithDefaults() *TenantCapabilities { } // GetTargetedInstanceCreation returns the TargetedInstanceCreation field value if set, zero value otherwise. +// Deprecated func (o *TenantCapabilities) GetTargetedInstanceCreation() bool { if o == nil || IsNil(o.TargetedInstanceCreation) { var ret bool @@ -54,6 +56,7 @@ func (o *TenantCapabilities) GetTargetedInstanceCreation() bool { // GetTargetedInstanceCreationOk returns a tuple with the TargetedInstanceCreation field value if set, nil otherwise // and a boolean to check if the value has been set. +// Deprecated func (o *TenantCapabilities) GetTargetedInstanceCreationOk() (*bool, bool) { if o == nil || IsNil(o.TargetedInstanceCreation) { return nil, false @@ -71,6 +74,7 @@ func (o *TenantCapabilities) HasTargetedInstanceCreation() bool { } // SetTargetedInstanceCreation gets a reference to the given bool and assigns it to the TargetedInstanceCreation field. +// Deprecated func (o *TenantCapabilities) SetTargetedInstanceCreation(v bool) { o.TargetedInstanceCreation = &v } diff --git a/rest-api/sdk/standard/model_vpc.go b/rest-api/sdk/standard/model_vpc.go index 661b28e509..1ae3b32e41 100644 --- a/rest-api/sdk/standard/model_vpc.go +++ b/rest-api/sdk/standard/model_vpc.go @@ -50,9 +50,8 @@ type VPC struct { // Propagation details for the attached Network Security Group NetworkSecurityGroupPropagationDetails *NetworkSecurityGroupPropagationDetails `json:"networkSecurityGroupPropagationDetails,omitempty"` // ID of the default NVLink Logical Partition that GPUs for all Instances in the VPC will attach to - NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` - // String key-value pairs describing VPC labels - Labels map[string]string `json:"labels,omitempty"` + NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` + Labels map[string]string `json:"labels,omitempty"` // Status of the VPC Status *VpcStatus `json:"status,omitempty"` // History of status changes for the VPC diff --git a/rest-api/sdk/standard/model_vpc_create_request.go b/rest-api/sdk/standard/model_vpc_create_request.go index d90ec62bca..5547c1fedd 100644 --- a/rest-api/sdk/standard/model_vpc_create_request.go +++ b/rest-api/sdk/standard/model_vpc_create_request.go @@ -41,9 +41,8 @@ type VpcCreateRequest struct { // Explicitly requested VNI for the VPC Vni NullableInt32 `json:"vni,omitempty"` // ID of the default NVLink Logical Partition that GPUs for all Instances in the VPC will attach to - NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` - // String key-value pairs describing VPC labels. Up to 10 key-value pairs can be specified - Labels map[string]string `json:"labels,omitempty"` + NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } type _VpcCreateRequest VpcCreateRequest diff --git a/rest-api/sdk/standard/model_vpc_update_request.go b/rest-api/sdk/standard/model_vpc_update_request.go index f760539eb3..aa926dfadc 100644 --- a/rest-api/sdk/standard/model_vpc_update_request.go +++ b/rest-api/sdk/standard/model_vpc_update_request.go @@ -29,9 +29,8 @@ type VpcUpdateRequest struct { // ID of the Network Security Group to attach to the VPC NetworkSecurityGroupId NullableString `json:"networkSecurityGroupId,omitempty"` // ID of the default NVLink Logical Partition that GPUs for all Instances in the VPC will attach to. Can only be updated if VPC currently has no active Instances - NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` - // Update labels of the VPC. Up to 10 key-value pairs can be specified. The labels will be replaced with the labels sent in the request. Any labels not included in the request will be removed. To retain existing labels, fetch them first and include them in this request. - Labels map[string]string `json:"labels,omitempty"` + NvLinkLogicalPartitionId NullableString `json:"nvLinkLogicalPartitionId,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } // NewVpcUpdateRequest instantiates a new VpcUpdateRequest object From 060caee70df84e090ed661268ffde8d5575fcc49 Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Wed, 1 Jul 2026 14:48:14 -0700 Subject: [PATCH 02/13] Fixed test --- rest-api/api/pkg/api/model/tenant_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rest-api/api/pkg/api/model/tenant_test.go b/rest-api/api/pkg/api/model/tenant_test.go index 6dfea33aed..fe63dd751b 100644 --- a/rest-api/api/pkg/api/model/tenant_test.go +++ b/rest-api/api/pkg/api/model/tenant_test.go @@ -39,6 +39,9 @@ func TestNewAPITenant(t *testing.T) { Created: dbtn.Created, Updated: dbtn.Updated, } + for _, deprecation := range tenantCapabilityDeprecations { + tnAPITenant.Deprecations = append(tnAPITenant.Deprecations, NewAPIDeprecation(deprecation)) + } tests := []struct { name string From e08621bfeeca5e52bd497c31aea0818ca3c8fcde Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Tue, 7 Jul 2026 14:31:34 -0700 Subject: [PATCH 03/13] Removed function and moved inline --- rest-api/api/pkg/api/handler/tenantaccount.go | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/rest-api/api/pkg/api/handler/tenantaccount.go b/rest-api/api/pkg/api/handler/tenantaccount.go index 5a43095414..2ab5cdfe93 100644 --- a/rest-api/api/pkg/api/handler/tenantaccount.go +++ b/rest-api/api/pkg/api/handler/tenantaccount.go @@ -569,10 +569,16 @@ func (gtah GetTenantAccountHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Status Details for TenantAccount", nil) } - tenantSites, err := loadTenantSitesForTenantAccount(ctx, gtah.dbSession, ta) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Tenant Sites for Tenant Account") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Sites for Tenant Account", nil) + var tenantSites []cdbm.TenantSite + if ta != nil && ta.TenantID != nil { + tsDAO := cdbm.NewTenantSiteDAO(gtah.dbSession) + tenantSites, _, err = tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{*ta.TenantID}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{"Site"}) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Tenant Sites for Tenant Account") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Sites for Tenant Account", nil) + } } // Create response @@ -691,22 +697,6 @@ func (utah UpdateTenantAccountHandler) Handle(c echo.Context) error { return utah.handleTenantInviteAcceptance(c, ctx, logger, org, dbUser, taID, apiRequest) } -func loadTenantSitesForTenantAccount(ctx context.Context, dbSession *cdb.Session, ta *cdbm.TenantAccount) ([]cdbm.TenantSite, error) { - if ta == nil || ta.TenantID == nil { - return nil, nil - } - - tsDAO := cdbm.NewTenantSiteDAO(dbSession) - tenantSites, _, err := tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ - TenantIDs: []uuid.UUID{*ta.TenantID}, - }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{"Site"}) - if err != nil { - return nil, err - } - - return tenantSites, nil -} - func (utah UpdateTenantAccountHandler) handleTenantInviteAcceptance(c echo.Context, ctx context.Context, logger zerolog.Logger, org string, dbUser *cdbm.User, taID uuid.UUID, apiRequest model.APITenantAccountUpdateRequest) error { taDAO := cdbm.NewTenantAccountDAO(utah.dbSession) @@ -912,10 +902,16 @@ func (utah UpdateTenantAccountHandler) handleProviderSiteCapabilitiesUpdate(c ec return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve updated Tenant Account", nil) } - tenantSites, err := loadTenantSitesForTenantAccount(ctx, utah.dbSession, ta) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Tenant Sites for updated Tenant Account") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Sites for Tenant Account", nil) + var tenantSites []cdbm.TenantSite + if ta != nil && ta.TenantID != nil { + tsDAO := cdbm.NewTenantSiteDAO(utah.dbSession) + tenantSites, _, err = tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{*ta.TenantID}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{"Site"}) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Tenant Sites for updated Tenant Account") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Sites for Tenant Account", nil) + } } sdDAO := cdbm.NewStatusDetailDAO(utah.dbSession) From f3a12b7969d9afd1b7ebef856d767ad154cb6b3c Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Tue, 7 Jul 2026 17:01:09 -0700 Subject: [PATCH 04/13] Addressed review comment by cr --- rest-api/api/pkg/api/handler/machine.go | 113 ++++++++++++------ rest-api/api/pkg/api/handler/machine_test.go | 23 ++++ rest-api/api/pkg/api/handler/tenantaccount.go | 10 +- .../api/pkg/api/handler/util/common/common.go | 55 +++++++-- .../api/handler/util/common/common_test.go | 97 ++++++++++++++- .../api/model/tenantaccountsitecapability.go | 4 +- rest-api/db/pkg/db/model/tenantaccount.go | 16 +++ .../db/pkg/db/model/tenantaccount_test.go | 41 +++++++ rest-api/db/pkg/db/model/tenantsite.go | 38 +++--- rest-api/db/pkg/db/model/tenantsite_test.go | 25 ++++ ...120000_tenant_account_site_capabilities.go | 3 +- 11 files changed, 353 insertions(+), 72 deletions(-) diff --git a/rest-api/api/pkg/api/handler/machine.go b/rest-api/api/pkg/api/handler/machine.go index 2a3015f12a..d9ee34495f 100644 --- a/rest-api/api/pkg/api/handler/machine.go +++ b/rest-api/api/pkg/api/handler/machine.go @@ -202,7 +202,7 @@ func (gamh GetAllMachineHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) } - // Validate role: Provider Admins or Viewers, or privileged Tenant Admins (TargetedInstanceCreation; see filters below). + // Validate role: Provider Admins or Viewers, or privileged Tenant Admins (site-effective TargetedInstanceCreation; see filters below). infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gamh.dbSession, org, dbUser, true, true) if apiError != nil { return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) @@ -240,27 +240,26 @@ func (gamh GetAllMachineHandler) Handle(c echo.Context) error { filterInput.InfrastructureProviderIDs = append(filterInput.InfrastructureProviderIDs, infrastructureProvider.ID) } + var tenantAccountProviderIDs []uuid.UUID if tenant != nil { - // Check if Tenant is privileged - if common.TenantHasTargetedInstanceCreation(tenant) { - // Get IDs for all Providers the privileged Tenant has an account with - taDAO := cdbm.NewTenantAccountDAO(gamh.dbSession) - tas, _, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{}) - if serr != nil { - logger.Error().Err(serr).Msg("error retrieving Tenant Accounts for privileged Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error retrieving Tenant Accounts for privileged Tenant", nil) - } + taDAO := cdbm.NewTenantAccountDAO(gamh.dbSession) + tas, _, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + Statuses: []string{cdbm.TenantAccountStatusReady}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{}) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving Tenant Accounts for Tenant") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error retrieving Tenant Accounts for Tenant", nil) + } - for _, ta := range tas { - filterInput.InfrastructureProviderIDs = append(filterInput.InfrastructureProviderIDs, ta.InfrastructureProviderID) - } + for _, ta := range tas { + tenantAccountProviderIDs = append(tenantAccountProviderIDs, ta.InfrastructureProviderID) } } // Validate site id if provided qSiteID := c.QueryParam("siteId") + var filterSite *cdbm.Site if qSiteID != "" { site, serr := common.GetSiteFromIDString(ctx, nil, qSiteID, gamh.dbSession) if serr != nil { @@ -270,6 +269,7 @@ func (gamh GetAllMachineHandler) Handle(c echo.Context) error { logger.Error().Err(serr).Str("Site ID", qSiteID).Msg("error retrieving Site specified in query") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error retrieving Site specified in query", nil) } + filterSite = site isAssociated := false if infrastructureProvider != nil { @@ -280,8 +280,7 @@ func (gamh GetAllMachineHandler) Handle(c echo.Context) error { } if !isAssociated && tenant != nil { - // We've already populated the filter with Providers the Tenant has an account with - isAssociated = slices.Contains(filterInput.InfrastructureProviderIDs, site.InfrastructureProviderID) + isAssociated = slices.Contains(tenantAccountProviderIDs, site.InfrastructureProviderID) } if isAssociated { @@ -292,6 +291,51 @@ func (gamh GetAllMachineHandler) Handle(c echo.Context) error { } } + if tenant != nil { + if filterSite != nil { + enabledForSite, serr := common.EffectiveTargetedInstanceCreation(ctx, nil, gamh.dbSession, tenant, filterSite.ID) + if serr != nil { + logger.Error().Err(serr).Msg("error checking effective targeted instance creation for Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify privileges for Site", nil) + } + if enabledForSite { + filterInput.InfrastructureProviderIDs = append(filterInput.InfrastructureProviderIDs, filterSite.InfrastructureProviderID) + } + } else { + siteDAO := cdbm.NewSiteDAO(gamh.dbSession) + privilegedProviderIDs := make(map[uuid.UUID]struct{}) + for _, providerID := range tenantAccountProviderIDs { + if _, listed := privilegedProviderIDs[providerID]; listed { + continue + } + + sites, _, serr := siteDAO.GetAll(ctx, nil, cdbm.SiteFilterInput{ + InfrastructureProviderIDs: []uuid.UUID{providerID}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving Sites for privileged Tenant") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error retrieving Sites for privileged Tenant", nil) + } + + for _, site := range sites { + enabledForSite, serr := common.EffectiveTargetedInstanceCreation(ctx, nil, gamh.dbSession, tenant, site.ID) + if serr != nil { + logger.Error().Err(serr).Msg("error checking effective targeted instance creation for Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify privileges for Site", nil) + } + if enabledForSite { + privilegedProviderIDs[providerID] = struct{}{} + break + } + } + } + + for providerID := range privilegedProviderIDs { + filterInput.InfrastructureProviderIDs = append(filterInput.InfrastructureProviderIDs, providerID) + } + } + } + // Validate InstanceType ID if provided qInstanceTypeID := qParams["instanceTypeId"] if len(qInstanceTypeID) > 0 { @@ -614,23 +658,14 @@ func (gmh GetMachineHandler) Handle(c echo.Context) error { isAssociated = true isProviderOrPrivilegedTenant = true } else if tenant != nil { - // Check if Tenant is privileged - if common.TenantHasTargetedInstanceCreation(tenant) { - // Check if privileged Tenant has an account with Infrastructure Provider - taDAO := cdbm.NewTenantAccountDAO(gmh.dbSession) - _, taCount, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ - InfrastructureProviderID: &machine.InfrastructureProviderID, - TenantIDs: []uuid.UUID{tenant.ID}, - }, cdbp.PageInput{}, []string{}) - if serr != nil { - logger.Error().Err(serr).Msg("error retrieving Tenant Account for Site") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error retrieving Tenant Account for Site", nil) - } - - if taCount > 0 { - isAssociated = true - isProviderOrPrivilegedTenant = true - } + enabledForSite, serr := common.EffectiveTargetedInstanceCreation(ctx, nil, gmh.dbSession, tenant, machine.SiteID) + if serr != nil { + logger.Error().Err(serr).Msg("error checking effective targeted instance creation for Machine's Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error verifying capability for Machine's Site", nil) + } + if enabledForSite { + isAssociated = true + isProviderOrPrivilegedTenant = true } else { // if not privileged, check if the machine is associated with an Instance belonging to the org's Tenant instanceDAO := cdbm.NewInstanceDAO(gmh.dbSession) @@ -718,8 +753,14 @@ func (umh UpdateMachineHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) } - // Validate role, only Provider Admins or Tenant Admins with TargetedInstanceCreation capability are allowed to update Machine - infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, umh.dbSession, org, dbUser, false, true) + // Validate role: Provider Admins, or Tenant Admins whose site-effective + // TargetedInstanceCreation capability is enforced below against + // machine.SiteID. We intentionally do not request the privileged-tenant + // pre-gate here (requirePrivilegedTenant=false): that gate keys off the + // legacy tenant-level flag and would reject TenantAccount-privileged + // tenants before machine.SiteID is known. The site-scoped + // EffectiveTargetedInstanceCreation check is the authoritative decision. + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, umh.dbSession, org, dbUser, false, false) if apiError != nil { return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } diff --git a/rest-api/api/pkg/api/handler/machine_test.go b/rest-api/api/pkg/api/handler/machine_test.go index 717bd14ec8..cf1c9987a8 100644 --- a/rest-api/api/pkg/api/handler/machine_test.go +++ b/rest-api/api/pkg/api/handler/machine_test.go @@ -206,6 +206,25 @@ func testMachineUpdateTenantCapability(t *testing.T, dbSession *cdb.Session, tn return tn } +func testMachineEnableTenantAccountTargetedInstanceCreation(t *testing.T, dbSession *cdb.Session, ip *cdbm.InfrastructureProvider, tenantID uuid.UUID) { + taDAO := cdbm.NewTenantAccountDAO(dbSession) + tas, _, err := taDAO.GetAll(context.Background(), nil, cdbm.TenantAccountFilterInput{ + InfrastructureProviderID: &ip.ID, + TenantIDs: []uuid.UUID{tenantID}, + Statuses: []string{cdbm.TenantAccountStatusReady}, + }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) + assert.Nil(t, err) + assert.NotEmpty(t, tas) + + _, err = taDAO.Update(context.Background(), nil, cdbm.TenantAccountUpdateInput{ + TenantAccountID: tas[0].ID, + Config: &cdbm.TenantAccountConfigUpdateInput{ + TargetedInstanceCreation: cutil.GetPtr(true), + }, + }) + assert.Nil(t, err) +} + func testMachineBuildVpc(t *testing.T, dbSession *cdb.Session, ip *cdbm.InfrastructureProvider, site *cdbm.Site, tenant *cdbm.Tenant, org, name string) *cdbm.Vpc { vpc := &cdbm.Vpc{ ID: uuid.New(), @@ -315,6 +334,7 @@ func TestMachineHandler_Get(t *testing.T) { tenant3 := testMachineBuildTenant(t, dbSession, tnOrg3, "test-tenant-o3") _ = testMachineUpdateTenantCapability(t, dbSession, tenant3) _ = common.TestBuildTenantAccount(t, dbSession, ip3, &tenant3.ID, tnOrg3, cdbm.TenantAccountStatusReady, tnuo3) + testMachineEnableTenantAccountTargetedInstanceCreation(t, dbSession, ip3, tenant3.ID) common.TestBuildTenantSite(t, dbSession, tenant, site, ipu) common.TestBuildTenantSite(t, dbSession, tenant2, site2, ipu) @@ -916,6 +936,7 @@ func TestMachineHandler_GetAll(t *testing.T) { tenant7 := testMachineBuildTenant(t, dbSession, tnOrg7, "test-tenant7") _ = testMachineUpdateTenantCapability(t, dbSession, tenant7) _ = common.TestBuildTenantAccount(t, dbSession, ip, &tenant7.ID, tnOrg7, cdbm.TenantAccountStatusReady, tnu7) + testMachineEnableTenantAccountTargetedInstanceCreation(t, dbSession, ip, tenant7.ID) tnOrg8 := "test-tn-org-8" tnu8 := testMachineBuildUser(t, dbSession, uuid.NewString(), []string{tnOrg8}, tnRoles) @@ -1640,6 +1661,7 @@ func TestMachineHandler_Update(t *testing.T) { tenant2 := testMachineBuildTenant(t, dbSession, tnOrg2, "testTenant2") _ = testMachineUpdateTenantCapability(t, dbSession, tenant2) _ = common.TestBuildTenantAccount(t, dbSession, ip, &tenant2.ID, tnOrg2, cdbm.TenantAccountStatusReady, tnu2) + testMachineEnableTenantAccountTargetedInstanceCreation(t, dbSession, ip, tenant2.ID) instanceType1 := testMachineBuildInstanceType(t, dbSession, ip, site, "testInstanceType1") instanceType2 := testMachineBuildInstanceType(t, dbSession, ip, site, "testInstanceType2") @@ -3262,6 +3284,7 @@ func TestMachineHandler_GetDpuMachines(t *testing.T) { tenantPriv := testMachineBuildTenant(t, dbSession, tnOrgPriv, "test-tenant-priv") _ = testMachineUpdateTenantCapability(t, dbSession, tenantPriv) _ = common.TestBuildTenantAccount(t, dbSession, ip, &tenantPriv.ID, tnOrgPriv, cdbm.TenantAccountStatusReady, tnuPriv) + testMachineEnableTenantAccountTargetedInstanceCreation(t, dbSession, ip, tenantPriv.ID) _ = testMachineBuildTenant(t, dbSession, tnOrgRegular, "test-tenant-regular") ist := testMachineBuildInstanceType(t, dbSession, ip, site, "instance-type-1") diff --git a/rest-api/api/pkg/api/handler/tenantaccount.go b/rest-api/api/pkg/api/handler/tenantaccount.go index 2ab5cdfe93..ad2a401b45 100644 --- a/rest-api/api/pkg/api/handler/tenantaccount.go +++ b/rest-api/api/pkg/api/handler/tenantaccount.go @@ -741,7 +741,15 @@ func (utah UpdateTenantAccountHandler) handleTenantInviteAcceptance(c echo.Conte // Handle database updates -- both tenant account and status detail err = cdb.WithTx(ctx, utah.dbSession, func(tx *cdb.Tx) error { var derr error - uta, derr = taDAO.Update(ctx, tx, cdbm.TenantAccountUpdateInput{TenantAccountID: taID, TenantContactID: cutil.GetPtr(dbUser.ID), Status: cutil.GetPtr(cdbm.TenantAccountStatusReady)}) + updateInput := cdbm.TenantAccountUpdateInput{ + TenantAccountID: taID, + TenantContactID: cutil.GetPtr(dbUser.ID), + Status: cutil.GetPtr(cdbm.TenantAccountStatusReady), + } + if cfg := cdbm.ConfigUpdateFromLegacyTenantTargetedInstanceCreation(tn); cfg != nil { + updateInput.Config = cfg + } + uta, derr = taDAO.Update(ctx, tx, updateInput) if derr != nil { logger.Error().Err(derr).Msg("error updating TenantAccount in DB") return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update TenantAccount", nil) diff --git a/rest-api/api/pkg/api/handler/util/common/common.go b/rest-api/api/pkg/api/handler/util/common/common.go index e6dac671b1..9a31d14863 100644 --- a/rest-api/api/pkg/api/handler/util/common/common.go +++ b/rest-api/api/pkg/api/handler/util/common/common.go @@ -1494,22 +1494,52 @@ func IsTenant(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session return nil, cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve tenant for org, DB error", nil) } - if requirePrivileged && !TenantHasTargetedInstanceCreation(tenant) { - return nil, cutil.NewAPIError(http.StatusForbidden, "Tenant does not have Targeted Instance Creation capability enabled", nil) + if requirePrivileged { + privileged, perr := TenantHasTargetedInstanceCreation(ctx, nil, dbSession, tenant) + if perr != nil { + logger.Error().Err(perr).Msg("error resolving privileged TenantAccount for Tenant") + return nil, cutil.NewAPIError(http.StatusInternalServerError, "Failed to resolve Tenant capability, DB error", nil) + } + if !privileged { + return nil, cutil.NewAPIError(http.StatusForbidden, "Tenant does not have Targeted Instance Creation capability enabled", nil) + } } return tenant, nil } // TenantHasTargetedInstanceCreation reports whether the Tenant has the -// TargetedInstanceCreation capability enabled at the tenant level (the legacy -// "ceiling"). It is nil-safe so callers don't have to repeat the tenant/Config -// nil checks. +// TargetedInstanceCreation capability enabled on any Ready TenantAccount (the +// provider-agnostic "ceiling"). It is nil-safe so callers don't have to repeat +// the tenant nil check. // -// This is the coarse filter for discovery/listing paths without site context. -// Site-scoped capability actions should use EffectiveTargetedInstanceCreation. -func TenantHasTargetedInstanceCreation(tenant *cdbm.Tenant) bool { - return tenant != nil && tenant.Config != nil && tenant.Config.TargetedInstanceCreation +// This is the coarse filter for discovery/listing paths that lack a specific +// Site context; it deliberately no longer reads the deprecated tenant-level +// flag (tenant.Config.TargetedInstanceCreation), which is superseded by +// TenantAccount.config. Site-scoped capability actions must use +// EffectiveTargetedInstanceCreation, which additionally honors per-site +// TenantSite.config overrides. +func TenantHasTargetedInstanceCreation(ctx context.Context, tx *cdb.Tx, dbSession *cdb.Session, tenant *cdbm.Tenant) (bool, error) { + if tenant == nil { + return false, nil + } + + taDAO := cdbm.NewTenantAccountDAO(dbSession) + tas, _, err := taDAO.GetAll(ctx, tx, cdbm.TenantAccountFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + Statuses: []string{cdbm.TenantAccountStatusReady}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if err != nil { + return false, err + } + + for _, ta := range tas { + if ta.Config.TargetedInstanceCreation { + return true, nil + } + } + + return false, nil } // EffectiveTargetedInstanceCreation reports whether the TargetedInstanceCreation @@ -1618,7 +1648,12 @@ func IsProviderOrTenant(ctx context.Context, logger zerolog.Logger, dbSession *c } if tenant != nil && requirePrivilegedTenant { - if !TenantHasTargetedInstanceCreation(tenant) { + privileged, perr := TenantHasTargetedInstanceCreation(ctx, nil, dbSession, tenant) + if perr != nil { + logger.Error().Err(perr).Msg("error resolving privileged TenantAccount for Tenant") + return nil, nil, cutil.NewAPIError(http.StatusInternalServerError, "Failed to resolve Tenant capability, DB error", nil) + } + if !privileged { if infrastructureProvider == nil { return nil, nil, cutil.NewAPIError(http.StatusForbidden, "Tenant does not have targeted Instance creation capability enabled", nil) } diff --git a/rest-api/api/pkg/api/handler/util/common/common_test.go b/rest-api/api/pkg/api/handler/util/common/common_test.go index d95cbac3da..296c4df957 100644 --- a/rest-api/api/pkg/api/handler/util/common/common_test.go +++ b/rest-api/api/pkg/api/handler/util/common/common_test.go @@ -2868,20 +2868,109 @@ func TestEvaluateInfiniBandRequestAgainstMachineCaps(t *testing.T) { } func TestTenantHasTargetedInstanceCreation(t *testing.T) { + ctx := context.Background() + dbSession := testCommonInitDB(t) + defer dbSession.Close() + + testCommonSetupSchema(t, dbSession) + + org := "test-priv-org" + user := testCommonBuildUser(t, dbSession, uuid.NewString(), []string{org}, []string{authz.ProviderAdminRole}) + ip := testCommonBuildInfrastructureProvider(t, dbSession, "Test Provider", org, user) + ip2 := testCommonBuildInfrastructureProvider(t, dbSession, "Test Provider 2", org+"-2", user) + + tnDAO := cdbm.NewTenantDAO(dbSession) + + // Tenant with a Ready TenantAccount that enables TargetedInstanceCreation. + enabledTenant, err := tnDAO.Create(ctx, nil, cdbm.TenantCreateInput{ + Name: "enabled-tenant", + Org: org + "-enabled", + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + // Tenant whose only Ready TenantAccount leaves the capability disabled. + disabledTenant, err := tnDAO.Create(ctx, nil, cdbm.TenantCreateInput{ + Name: "disabled-tenant", + Org: org + "-disabled", + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + // Tenant with the capability enabled but only on a non-Ready TenantAccount. + pendingTenant, err := tnDAO.Create(ctx, nil, cdbm.TenantCreateInput{ + Name: "pending-tenant", + Org: org + "-pending", + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + taDAO := cdbm.NewTenantAccountDAO(dbSession) + _, err = taDAO.Create(ctx, nil, cdbm.TenantAccountCreateInput{ + AccountNumber: uuid.NewString(), + TenantID: &enabledTenant.ID, + TenantOrg: enabledTenant.Org, + InfrastructureProviderID: ip.ID, + InfrastructureProviderOrg: ip.Org, + Status: cdbm.TenantAccountStatusReady, + Config: &cdbm.TenantAccountConfig{TargetedInstanceCreation: true}, + CreatedBy: user.ID, + }) + assert.Nil(t, err) + // A second Ready account (different provider) with the capability disabled + // must not mask the enabled one. + _, err = taDAO.Create(ctx, nil, cdbm.TenantAccountCreateInput{ + AccountNumber: uuid.NewString(), + TenantID: &enabledTenant.ID, + TenantOrg: enabledTenant.Org, + InfrastructureProviderID: ip2.ID, + InfrastructureProviderOrg: ip2.Org, + Status: cdbm.TenantAccountStatusReady, + Config: &cdbm.TenantAccountConfig{TargetedInstanceCreation: false}, + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + _, err = taDAO.Create(ctx, nil, cdbm.TenantAccountCreateInput{ + AccountNumber: uuid.NewString(), + TenantID: &disabledTenant.ID, + TenantOrg: disabledTenant.Org, + InfrastructureProviderID: ip.ID, + InfrastructureProviderOrg: ip.Org, + Status: cdbm.TenantAccountStatusReady, + Config: &cdbm.TenantAccountConfig{TargetedInstanceCreation: false}, + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + _, err = taDAO.Create(ctx, nil, cdbm.TenantAccountCreateInput{ + AccountNumber: uuid.NewString(), + TenantID: &pendingTenant.ID, + TenantOrg: pendingTenant.Org, + InfrastructureProviderID: ip.ID, + InfrastructureProviderOrg: ip.Org, + Status: cdbm.TenantAccountStatusPending, + Config: &cdbm.TenantAccountConfig{TargetedInstanceCreation: true}, + CreatedBy: user.ID, + }) + assert.Nil(t, err) + tests := []struct { name string tenant *cdbm.Tenant expected bool }{ {name: "nil tenant", tenant: nil, expected: false}, - {name: "nil config", tenant: &cdbm.Tenant{Config: nil}, expected: false}, - {name: "disabled", tenant: &cdbm.Tenant{Config: &cdbm.TenantConfig{}}, expected: false}, - {name: "enabled", tenant: &cdbm.Tenant{Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}}, expected: true}, + {name: "enabled via Ready TenantAccount", tenant: enabledTenant, expected: true}, + {name: "disabled TenantAccount config", tenant: disabledTenant, expected: false}, + {name: "enabled only on non-Ready TenantAccount", tenant: pendingTenant, expected: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, TenantHasTargetedInstanceCreation(tc.tenant)) + got, gerr := TenantHasTargetedInstanceCreation(ctx, nil, dbSession, tc.tenant) + assert.Nil(t, gerr) + assert.Equal(t, tc.expected, got) }) } } diff --git a/rest-api/api/pkg/api/model/tenantaccountsitecapability.go b/rest-api/api/pkg/api/model/tenantaccountsitecapability.go index c89f09cb84..a6df787531 100644 --- a/rest-api/api/pkg/api/model/tenantaccountsitecapability.go +++ b/rest-api/api/pkg/api/model/tenantaccountsitecapability.go @@ -7,10 +7,10 @@ import ( "fmt" "slices" - validation "github.com/go-ozzo/ozzo-validation/v4" - validationis "github.com/go-ozzo/ozzo-validation/v4/is" cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + validation "github.com/go-ozzo/ozzo-validation/v4" + validationis "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/google/uuid" ) diff --git a/rest-api/db/pkg/db/model/tenantaccount.go b/rest-api/db/pkg/db/model/tenantaccount.go index 626dfbb543..8316a59e5b 100644 --- a/rest-api/db/pkg/db/model/tenantaccount.go +++ b/rest-api/db/pkg/db/model/tenantaccount.go @@ -119,6 +119,22 @@ type TenantAccountConfigUpdateInput struct { TargetedInstanceCreation *bool `json:"targetedInstanceCreation,omitempty"` } +// ConfigUpdateFromLegacyTenantTargetedInstanceCreation returns a partial +// TenantAccountConfigUpdateInput that enables TargetedInstanceCreation when the +// legacy tenant-level flag is set. Apply when a TenantAccount becomes Ready so +// Invited or Pending rows inherit the capability even if they were created after +// the migration backfill ran. +func ConfigUpdateFromLegacyTenantTargetedInstanceCreation(tenant *Tenant) *TenantAccountConfigUpdateInput { + if tenant == nil || tenant.Config == nil || !tenant.Config.TargetedInstanceCreation { + return nil + } + + val := true + return &TenantAccountConfigUpdateInput{ + TargetedInstanceCreation: &val, + } +} + // TenantAccount represents a tenant account - the relationship between a Tenant and an Infrastructure Provider type TenantAccount struct { bun.BaseModel `bun:"table:tenant_account,alias:ta"` diff --git a/rest-api/db/pkg/db/model/tenantaccount_test.go b/rest-api/db/pkg/db/model/tenantaccount_test.go index 612532a052..b7d66ca732 100644 --- a/rest-api/db/pkg/db/model/tenantaccount_test.go +++ b/rest-api/db/pkg/db/model/tenantaccount_test.go @@ -1247,3 +1247,44 @@ func TestTenantAccountSQLDAO_Delete(t *testing.T) { }) } } + +func TestConfigUpdateFromLegacyTenantTargetedInstanceCreation(t *testing.T) { + tests := []struct { + name string + tenant *Tenant + expected *TenantAccountConfigUpdateInput + }{ + { + name: "nil tenant", + tenant: nil, + expected: nil, + }, + { + name: "nil config", + tenant: &Tenant{Config: nil}, + expected: nil, + }, + { + name: "legacy flag disabled", + tenant: &Tenant{Config: &TenantConfig{}}, + expected: nil, + }, + { + name: "legacy flag enabled", + tenant: &Tenant{Config: &TenantConfig{TargetedInstanceCreation: true}}, + expected: &TenantAccountConfigUpdateInput{TargetedInstanceCreation: cutil.GetPtr(true)}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ConfigUpdateFromLegacyTenantTargetedInstanceCreation(tc.tenant) + if tc.expected == nil { + assert.Nil(t, got) + return + } + assert.NotNil(t, got) + assert.Equal(t, *tc.expected.TargetedInstanceCreation, *got.TargetedInstanceCreation) + }) + } +} diff --git a/rest-api/db/pkg/db/model/tenantsite.go b/rest-api/db/pkg/db/model/tenantsite.go index e2032ff670..c9565b0fa8 100644 --- a/rest-api/db/pkg/db/model/tenantsite.go +++ b/rest-api/db/pkg/db/model/tenantsite.go @@ -12,6 +12,7 @@ import ( "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" stracer "github.com/NVIDIA/infra-controller/rest-api/db/pkg/tracer" "github.com/google/uuid" + "github.com/pkg/errors" "github.com/uptrace/bun" ) @@ -45,18 +46,18 @@ type TenantSiteConfigUpdateInput struct { type TenantSite struct { bun.BaseModel `bun:"table:tenant_site,alias:ts"` - ID uuid.UUID `bun:"type:uuid,pk"` - TenantID uuid.UUID `bun:"tenant_id,type:uuid,notnull"` - Tenant *Tenant `bun:"rel:belongs-to,join:tenant_id=id"` - TenantOrg string `bun:"tenant_org,notnull"` - SiteID uuid.UUID `bun:"site_id,type:uuid,notnull"` - Site *Site `bun:"rel:belongs-to,join:site_id=id"` - EnableSerialConsole bool `bun:"enable_serial_console,notnull"` + ID uuid.UUID `bun:"type:uuid,pk"` + TenantID uuid.UUID `bun:"tenant_id,type:uuid,notnull"` + Tenant *Tenant `bun:"rel:belongs-to,join:tenant_id=id"` + TenantOrg string `bun:"tenant_org,notnull"` + SiteID uuid.UUID `bun:"site_id,type:uuid,notnull"` + Site *Site `bun:"rel:belongs-to,join:site_id=id"` + EnableSerialConsole bool `bun:"enable_serial_console,notnull"` Config TenantSiteConfig `bun:"config,type:jsonb,notnull,default:'{}'::jsonb"` 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"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` + Deleted *time.Time `bun:"deleted,soft_delete"` + CreatedBy uuid.UUID `bun:"type:uuid,notnull"` } // TenantSiteCreateInput input parameters for Create method @@ -70,9 +71,9 @@ type TenantSiteCreateInput struct { // TenantSiteUpdateInput input parameters for Update method type TenantSiteUpdateInput struct { - TenantSiteID uuid.UUID - EnableSerialConsole *bool - Config *TenantSiteConfigUpdateInput + TenantSiteID uuid.UUID + EnableSerialConsole *bool + Config *TenantSiteConfigUpdateInput RemoveTargetedInstanceCreation bool } @@ -311,7 +312,12 @@ func (tssd TenantSiteSQLDAO) Update(ctx context.Context, tx *db.Tx, input Tenant } } - if input.Config != nil { + if input.Config != nil && input.RemoveTargetedInstanceCreation { + return nil, errors.Wrap(db.ErrInvalidParams, "Config and RemoveTargetedInstanceCreation are mutually exclusive") + } + + switch { + case input.Config != nil: _, err := db.GetIDB(tx, tssd.dbSession).NewUpdate(). Model(ts). Set("config = config || ?::jsonb, updated = current_timestamp", input.Config). @@ -320,9 +326,7 @@ func (tssd TenantSiteSQLDAO) Update(ctx context.Context, tx *db.Tx, input Tenant if err != nil { return nil, err } - } - - if input.RemoveTargetedInstanceCreation { + case input.RemoveTargetedInstanceCreation: _, err := db.GetIDB(tx, tssd.dbSession).NewUpdate(). Model(ts). Set("config = config - 'targetedInstanceCreation', updated = current_timestamp"). diff --git a/rest-api/db/pkg/db/model/tenantsite_test.go b/rest-api/db/pkg/db/model/tenantsite_test.go index 38dfa29792..8f9392c234 100644 --- a/rest-api/db/pkg/db/model/tenantsite_test.go +++ b/rest-api/db/pkg/db/model/tenantsite_test.go @@ -698,6 +698,31 @@ func TestTenantSiteSQLDAO_Update(t *testing.T) { } }) } + + t.Run("remove targetedInstanceCreation", func(t *testing.T) { + tsWithOverride := TestBuildTenantSite(t, dbSession, tn, TestBuildSite(t, dbSession, ip, "Test Site 2", ipu), + &TenantSiteConfig{TargetedInstanceCreation: cutil.GetPtr(false)}, tnu) + + tssd := TenantSiteSQLDAO{dbSession: dbSession} + got, err := tssd.Update(ctx, nil, TenantSiteUpdateInput{ + TenantSiteID: tsWithOverride.ID, + RemoveTargetedInstanceCreation: true, + }) + assert.NoError(t, err) + assert.Nil(t, got.Config.TargetedInstanceCreation) + }) + + t.Run("reject config merge with removal", func(t *testing.T) { + tssd := TenantSiteSQLDAO{dbSession: dbSession} + _, err := tssd.Update(ctx, nil, TenantSiteUpdateInput{ + TenantSiteID: ts.ID, + Config: &TenantSiteConfigUpdateInput{ + TargetedInstanceCreation: cutil.GetPtr(true), + }, + RemoveTargetedInstanceCreation: true, + }) + assert.ErrorIs(t, err, db.ErrInvalidParams) + }) } func TestTenantSiteSQLDAO_Delete(t *testing.T) { diff --git a/rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go b/rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go index f6b5245e77..2f6a224595 100644 --- a/rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go +++ b/rest-api/db/pkg/migrations/20260701120000_tenant_account_site_capabilities.go @@ -28,9 +28,8 @@ func init() { SET config = jsonb_set(COALESCE(ta.config, '{}'::jsonb), '{targetedInstanceCreation}', 'true'::jsonb, true) FROM tenant t WHERE ta.tenant_id = t.id - AND ta.status = ? AND COALESCE(t.config->>'targetedInstanceCreation', 'false') = 'true' - `, model.TenantAccountStatusReady) + `) handleError(tx, err) terr = tx.Commit() From 16a078814af7fac263495b5363d0ef186ffccfa0 Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Wed, 8 Jul 2026 09:37:53 -0700 Subject: [PATCH 05/13] Fixed failing test cases --- .../pkg/api/handler/dpureprovision_test.go | 2 +- .../api/pkg/api/handler/expectedmachine.go | 12 +++++++--- .../pkg/api/handler/expectedmachine_test.go | 4 +++- rest-api/api/pkg/api/handler/instance_test.go | 3 +++ rest-api/api/pkg/api/handler/machine.go | 11 ++++++++-- .../api/pkg/api/handler/machinehealth_test.go | 2 +- rest-api/api/pkg/api/handler/sku_test.go | 6 +++-- .../pkg/api/handler/util/common/testing.go | 22 +++++++++++++++++++ rest-api/api/pkg/api/handler/vpc_test.go | 3 +++ 9 files changed, 55 insertions(+), 10 deletions(-) diff --git a/rest-api/api/pkg/api/handler/dpureprovision_test.go b/rest-api/api/pkg/api/handler/dpureprovision_test.go index dbde902e52..beb4ae6f65 100644 --- a/rest-api/api/pkg/api/handler/dpureprovision_test.go +++ b/rest-api/api/pkg/api/handler/dpureprovision_test.go @@ -60,7 +60,7 @@ func TestReprovisionMachineDpuHandlerAllowsPrivilegedTenant(t *testing.T) { require.NoError(t, err) provider, err := cdbm.NewInfrastructureProviderDAO(fixture.DBSession).GetByID(ctx, nil, machine.InfrastructureProviderID, []string{}) require.NoError(t, err) - common.TestBuildTenantAccount(t, fixture.DBSession, provider, &tenant.ID, tenant.Org, cdbm.TenantAccountStatusReady, tenantUser) + common.TestBuildTenantAccountWithTargetedInstanceCreation(t, fixture.DBSession, provider, &tenant.ID, tenant.Org, cdbm.TenantAccountStatusReady, tenantUser) fixture.Org = tenantOrg fixture.User = tenantUser diff --git a/rest-api/api/pkg/api/handler/expectedmachine.go b/rest-api/api/pkg/api/handler/expectedmachine.go index 1ac411c377..507455f3a9 100644 --- a/rest-api/api/pkg/api/handler/expectedmachine.go +++ b/rest-api/api/pkg/api/handler/expectedmachine.go @@ -317,13 +317,19 @@ func (gaemh GetAllExpectedMachineHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // ensure our user is a provider for the org - infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gaemh.dbSession, org, dbUser, true, true) + // ensure our user is a provider or tenant for the org. We do not request the + // privileged-tenant pre-gate (requirePrivilegedTenant=false): a Tenant's + // visibility is scoped below to the Sites it has TenantSite access to (and + // only when TargetedInstanceCreation is enabled), so a non-privileged Tenant + // receives an empty list rather than a spurious 403. + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gaemh.dbSession, org, dbUser, true, false) if apiError != nil { return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } - filterInput := cdbm.ExpectedMachineFilterInput{} + // Initialize SiteIDs to a non-nil empty slice so an unscoped caller (e.g. a + // non-privileged Tenant) matches no Sites instead of every Site. + filterInput := cdbm.ExpectedMachineFilterInput{SiteIDs: []uuid.UUID{}} if infrastructureProvider != nil { // Get all Sites for the org's Infrastructure Provider diff --git a/rest-api/api/pkg/api/handler/expectedmachine_test.go b/rest-api/api/pkg/api/handler/expectedmachine_test.go index e49e617818..9f9dfec09b 100644 --- a/rest-api/api/pkg/api/handler/expectedmachine_test.go +++ b/rest-api/api/pkg/api/handler/expectedmachine_test.go @@ -1565,7 +1565,8 @@ func TestTenantWithTargetedInstanceCreationCapability(t *testing.T) { _, err = dbSession.DB.NewInsert().Model(tenantUser).Exec(ctx) assert.Nil(t, err) - // Create TenantAccount linking tenant to infrastructure provider + // Create TenantAccount linking tenant to infrastructure provider. Privilege + // (TargetedInstanceCreation) is resolved from the Ready TenantAccount config. tenantAccount := &cdbm.TenantAccount{ ID: uuid.New(), AccountNumber: "TA-12345", @@ -1573,6 +1574,7 @@ func TestTenantWithTargetedInstanceCreationCapability(t *testing.T) { TenantOrg: tenantOrg, InfrastructureProviderID: ip.ID, Status: cdbm.TenantAccountStatusReady, + Config: cdbm.TenantAccountConfig{TargetedInstanceCreation: true}, CreatedBy: tenantUser.ID, } _, err = dbSession.DB.NewInsert().Model(tenantAccount).Exec(ctx) diff --git a/rest-api/api/pkg/api/handler/instance_test.go b/rest-api/api/pkg/api/handler/instance_test.go index 4269c0c515..59b0563e76 100644 --- a/rest-api/api/pkg/api/handler/instance_test.go +++ b/rest-api/api/pkg/api/handler/instance_test.go @@ -739,6 +739,9 @@ func TestCreateInstanceHandler_Handle(t *testing.T) { tnu1 := testInstanceBuildUser(t, dbSession, "test-starfleet-id-2", tnOrg, tnOrgRoles) tn1 := testInstanceBuildTenant(t, dbSession, "test-tenant", tnOrg, tnu1) tn1 = testInstanceUpdateTenantCapability(t, dbSession, tn1) + // Privilege is resolved from a Ready TenantAccount config, so enable + // TargetedInstanceCreation on tn1's account with the site's provider. + _ = common.TestBuildTenantAccountWithTargetedInstanceCreation(t, dbSession, ip, &tn1.ID, tnOrg, cdbm.TenantAccountStatusReady, tnu1) ts1 := testBuildTenantSiteAssociation(t, dbSession, tnOrg, tn1.ID, st1.ID, tnu1.ID) assert.NotNil(t, ts1) diff --git a/rest-api/api/pkg/api/handler/machine.go b/rest-api/api/pkg/api/handler/machine.go index d9ee34495f..ab4a00b6db 100644 --- a/rest-api/api/pkg/api/handler/machine.go +++ b/rest-api/api/pkg/api/handler/machine.go @@ -202,8 +202,15 @@ func (gamh GetAllMachineHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) } - // Validate role: Provider Admins or Viewers, or privileged Tenant Admins (site-effective TargetedInstanceCreation; see filters below). - infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gamh.dbSession, org, dbUser, true, true) + // Validate role: Provider Admins or Viewers, or Tenant Admins. We do not + // request the privileged-tenant pre-gate here (requirePrivilegedTenant=false): + // that gate keys off a Ready TenantAccount without site context and would + // reject site-privileged tenants (or those whose privilege is per-site) + // before machine.SiteID is known. The site-scoped + // EffectiveTargetedInstanceCreation checks below are authoritative and + // filter results to the providers/sites where the capability is effective, + // yielding an empty list rather than a spurious 403. + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gamh.dbSession, org, dbUser, true, false) if apiError != nil { return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } diff --git a/rest-api/api/pkg/api/handler/machinehealth_test.go b/rest-api/api/pkg/api/handler/machinehealth_test.go index cd9633367d..1f6f157696 100644 --- a/rest-api/api/pkg/api/handler/machinehealth_test.go +++ b/rest-api/api/pkg/api/handler/machinehealth_test.go @@ -169,7 +169,7 @@ func configureMachineHealthFixtureForPrivilegedTenant(t *testing.T, fixture *com require.NoError(t, err) provider, err := cdbm.NewInfrastructureProviderDAO(fixture.DBSession).GetByID(ctx, nil, machine.InfrastructureProviderID, []string{}) require.NoError(t, err) - common.TestBuildTenantAccount(t, fixture.DBSession, provider, &tenant.ID, tenant.Org, cdbm.TenantAccountStatusReady, tenantUser) + common.TestBuildTenantAccountWithTargetedInstanceCreation(t, fixture.DBSession, provider, &tenant.ID, tenant.Org, cdbm.TenantAccountStatusReady, tenantUser) fixture.Org = tenantOrg fixture.User = tenantUser diff --git a/rest-api/api/pkg/api/handler/sku_test.go b/rest-api/api/pkg/api/handler/sku_test.go index f4a0d7c879..4b6b992d10 100644 --- a/rest-api/api/pkg/api/handler/sku_test.go +++ b/rest-api/api/pkg/api/handler/sku_test.go @@ -196,7 +196,8 @@ func TestGetAllSkuHandler_Handle(t *testing.T) { TenantID: &tenantWithCapability.ID, TenantOrg: tenantOrg, InfrastructureProviderID: infraProv.ID, - Status: "active", + Status: cdbm.TenantAccountStatusReady, + Config: cdbm.TenantAccountConfig{TargetedInstanceCreation: true}, } _, err = dbSession.DB.NewInsert().Model(tenantAccount).Exec(ctx) assert.Nil(t, err) @@ -479,7 +480,8 @@ func TestGetSkuHandler_Handle(t *testing.T) { TenantID: &tenantWithCapability.ID, TenantOrg: tenantOrg, InfrastructureProviderID: infraProv.ID, - Status: "active", + Status: cdbm.TenantAccountStatusReady, + Config: cdbm.TenantAccountConfig{TargetedInstanceCreation: true}, } _, err = dbSession.DB.NewInsert().Model(tenantAccount).Exec(ctx) assert.Nil(t, err) diff --git a/rest-api/api/pkg/api/handler/util/common/testing.go b/rest-api/api/pkg/api/handler/util/common/testing.go index 26d1af33a3..5b542f3237 100644 --- a/rest-api/api/pkg/api/handler/util/common/testing.go +++ b/rest-api/api/pkg/api/handler/util/common/testing.go @@ -340,6 +340,28 @@ func TestBuildTenantAccount(t *testing.T, dbSession *cdb.Session, ip *cdbm.Infra return ta } +// TestBuildTenantAccountWithTargetedInstanceCreation creates a test TenantAccount +// with the TargetedInstanceCreation capability enabled in its config. Privilege is +// resolved from a Ready TenantAccount config (see EffectiveTargetedInstanceCreation +// and TenantHasTargetedInstanceCreation), so tests that exercise privileged Tenant +// paths must enable it on the account rather than only on the legacy Tenant config. +func TestBuildTenantAccountWithTargetedInstanceCreation(t *testing.T, dbSession *cdb.Session, ip *cdbm.InfrastructureProvider, tenantID *uuid.UUID, tenantOrg string, status string, user *cdbm.User) *cdbm.TenantAccount { + taDAO := cdbm.NewTenantAccountDAO(dbSession) + + ta, err := taDAO.Create(context.Background(), nil, cdbm.TenantAccountCreateInput{ + AccountNumber: GenerateAccountNumber(), + TenantID: tenantID, + TenantOrg: tenantOrg, + InfrastructureProviderID: ip.ID, + Status: status, + Config: &cdbm.TenantAccountConfig{TargetedInstanceCreation: true}, + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + return ta +} + // TestBuildSite creates a test Site func TestBuildSite(t *testing.T, dbSession *cdb.Session, ip *cdbm.InfrastructureProvider, name string, user *cdbm.User) *cdbm.Site { stDAO := cdbm.NewSiteDAO(dbSession) diff --git a/rest-api/api/pkg/api/handler/vpc_test.go b/rest-api/api/pkg/api/handler/vpc_test.go index 98850ffb38..81154fd0f2 100644 --- a/rest-api/api/pkg/api/handler/vpc_test.go +++ b/rest-api/api/pkg/api/handler/vpc_test.go @@ -326,6 +326,9 @@ func TestCreateVPCHandler_Handle(t *testing.T) { }, }) assert.NoError(t, err) + // Privilege for `routingProfile` is resolved from a Ready TenantAccount + // config, so enable TargetedInstanceCreation on tn's account with the provider. + _ = common.TestBuildTenantAccountWithTargetedInstanceCreation(t, dbSession, ip, &tn.ID, tnOrg, cdbm.TenantAccountStatusReady, tnu) tnu2 := testVPCBuildUser(t, dbSession, "test-starfleet-id-3", tnOrg, tnOrgRoles) tn2 := testVPCBuildTenant(t, dbSession, "test-tenant-2", tnOrg, tnu2) From c7a2e04e045a5436d178ddcbae9988ccc6f4b7f4 Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Wed, 8 Jul 2026 09:51:28 -0700 Subject: [PATCH 06/13] reneraged openapi --- rest-api/docs/index.html | 86 ++++++++++++++++++++++++--------- rest-api/sdk/standard/client.go | 9 ---- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/rest-api/docs/index.html b/rest-api/docs/index.html index ea1ec69c3f..4697cc4b4c 100644 --- a/rest-api/docs/index.html +++ b/rest-api/docs/index.html @@ -792,11 +792,25 @@

Typical API Call Flow for Tenant

" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv">

Date/time the Tenant was created

updated
string <date-time>

Date/time when Tenant was last updated

-
object (TenantCapabilities)
object (TenantCapabilities)

Features that are enabled/disabled for Tenant

-
targetedInstanceCreation
boolean <uuid>

Indicates whether Tenant can create Instances by specifying Machine ID

-
Array of objects (Deprecation)

Deprecation notices for Tenant fields

+
Array
attribute
string or null

Name of the attribute that is deprecated. Omitted if queryParam or endpoint is being deprecated.

+
queryParam
string or null

Query parameter that is deprecated. Omitted if attribute or endpoint is being deprecated.

+
endpoint
string or null

API endpoint that is deprecated. Omitted if attribute or queryParam is being deprecated.

+
replacedBy
string or null

Name of the attribute, query parameter, or endpoint that replaces the deprecated item. Omitted if no replacement is available.

+
takeActionBy
string <date-time>

Date/time by which clients should migrate away from the deprecated API surface

+
notice
string

Message describing the deprecation

+
created
string <date-time>

Date/time when the Tenant Account was created

-
updated
string <date-time>
updated
string <date-time>

Date/time when the Tenant Account was last updated

-
Array of objects (TenantAccountSiteCapability)

Provider-scoped TargetedInstanceCreation settings for this Tenant Account

+
Array
scope
required
string (TenantAccountSiteCapabilityScope)
Enum: "global" "limited"
siteIds
Array of strings <uuid> [ items <uuid > ]

Required when scope is limited; must be omitted or empty when scope is global

+
targetedInstanceCreation
required
boolean

Whether TargetedInstanceCreation is enabled for the scope

+
created
string <date-time>

Date/time when the Tenant Account was created

-
updated
string <date-time>
updated
string <date-time>

Date/time when the Tenant Account was last updated

-
Array of objects (TenantAccountSiteCapability)

Provider-scoped TargetedInstanceCreation settings for this Tenant Account

+
Array
scope
required
string (TenantAccountSiteCapabilityScope)
Enum: "global" "limited"
siteIds
Array of strings <uuid> [ items <uuid > ]

Required when scope is limited; must be omitted or empty when scope is global

+
targetedInstanceCreation
required
boolean

Whether TargetedInstanceCreation is enabled for the scope

+
created
string <date-time>

Date/time when the Tenant Account was created

-
updated
string <date-time>
updated
string <date-time>

Date/time when the Tenant Account was last updated

-
Array of objects (TenantAccountSiteCapability)

Provider-scoped TargetedInstanceCreation settings for this Tenant Account

+
Array
scope
required
string (TenantAccountSiteCapabilityScope)
Enum: "global" "limited"
siteIds
Array of strings <uuid> [ items <uuid > ]

Required when scope is limited; must be omitted or empty when scope is global

+
targetedInstanceCreation
required
boolean

Whether TargetedInstanceCreation is enabled for the scope

+
Request Body schema: application/json

No parameters are required; an empty request body is sufficient.

-
object (TenantAccountUpdateRequest)

Request data to update a TenantAccount.

-

No params needed, an empty request will suffice.

-

Responses

Array of objects (TenantAccountSiteCapability)

Provider Admin replace payload for TargetedInstanceCreation scoping

+
Array
scope
required
string (TenantAccountSiteCapabilityScope)
Enum: "global" "limited"
siteIds
Array of strings <uuid> [ items <uuid > ]

Required when scope is limited; must be omitted or empty when scope is global

+
targetedInstanceCreation
required
boolean

Whether TargetedInstanceCreation is enabled for the scope

+

Responses

Response Schema: application/json
id
string <uuid>

Unique UUID v4 identifier for the Tenant Account

@@ -1232,9 +1268,15 @@

Typical API Call Flow for Tenant

" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv">

Message describing the deprecation

created
string <date-time>

Date/time when the Tenant Account was created

-
updated
string <date-time>
updated
string <date-time>

Date/time when the Tenant Account was last updated

-
Array of objects (TenantAccountSiteCapability)

Provider-scoped TargetedInstanceCreation settings for this Tenant Account

+
Array
scope
required
string (TenantAccountSiteCapabilityScope)
Enum: "global" "limited"
siteIds
Array of strings <uuid> [ items <uuid > ]

Required when scope is limited; must be omitted or empty when scope is global

+
targetedInstanceCreation
required
boolean

Whether TargetedInstanceCreation is enabled for the scope

+
object (TenantCapabilities)

Features that are enabled/disabled for Tenant

-
targetedInstanceCreation
boolean <uuid>

Indicates whether Tenant can create Instances by specifying Machine ID

+
targetedInstanceCreation
boolean
Deprecated

Deprecated in favor of TenantAccount.siteCapabilities. Indicates whether Tenant can create Instances by specifying Machine ID.

isMultiTenant
boolean

Indicates if this is a multi-tenant peering (VPCs from different tenants)

status
string (VpcPeeringStatus)
Enum: "Pending" "Configuring" "Requested" "Ready" "Deleting" "Error"
Typical API Call Flow for Tenant " class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv">

Display name of the org the Tenant belongs to

object (TenantCapabilities)

Features that are enabled/disabled for Tenant

-
targetedInstanceCreation
boolean <uuid>

Indicates whether Tenant can create Instances by specifying Machine ID

+
targetedInstanceCreation
boolean
Deprecated

Deprecated in favor of TenantAccount.siteCapabilities. Indicates whether Tenant can create Instances by specifying Machine ID.

isMultiTenant
boolean

Indicates if this is a multi-tenant peering (VPCs from different tenants)

status
string (VpcPeeringStatus)
Enum: "Pending" "Configuring" "Requested" "Ready" "Deleting" "Error"
Typical API Call Flow for Tenant