From d206b366a0122be679b670c7fb5a24be9034a81b Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Fri, 29 May 2026 15:40:41 -0700 Subject: [PATCH 1/3] feat: centralize tenant TargetedInstanceCreation capability check in a shared helper Signed-off-by: Hitesh Wadekar --- api/pkg/api/handler/expectedmachine.go | 2 +- api/pkg/api/handler/instance.go | 4 +-- api/pkg/api/handler/machine.go | 6 ++-- api/pkg/api/handler/site.go | 6 ++-- api/pkg/api/handler/sku.go | 4 +-- api/pkg/api/handler/util/common/common.go | 17 +++++++-- .../api/handler/util/common/common_test.go | 35 +++++++++++++++++++ api/pkg/api/handler/vpc.go | 2 +- 8 files changed, 62 insertions(+), 14 deletions(-) diff --git a/api/pkg/api/handler/expectedmachine.go b/api/pkg/api/handler/expectedmachine.go index 4beb7cb91..353209f97 100644 --- a/api/pkg/api/handler/expectedmachine.go +++ b/api/pkg/api/handler/expectedmachine.go @@ -58,7 +58,7 @@ func ValidateProviderOrTenantSiteAccess(ctx context.Context, logger zerolog.Logg hasAccess = tsCount > 0 // Check if Tenant is privileged - if !hasAccess && tenant.Config.TargetedInstanceCreation { + if !hasAccess && common.TenantHasTargetedInstanceCreation(tenant) { // Check if privileged tenant has an account with the Site's Infrastructure Provider taDAO := cdbm.NewTenantAccountDAO(dbSession) _, taCount, err := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ diff --git a/api/pkg/api/handler/instance.go b/api/pkg/api/handler/instance.go index 52f6d6cc3..6b3b48cee 100644 --- a/api/pkg/api/handler/instance.go +++ b/api/pkg/api/handler/instance.go @@ -830,7 +830,7 @@ func (cih CreateInstanceHandler) Handle(c echo.Context) error { // Begin validating Machine ID if apiRequest.MachineID != nil { - if tenant.Config == nil || !tenant.Config.TargetedInstanceCreation { + if !common.TenantHasTargetedInstanceCreation(tenant) { 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) } @@ -4746,7 +4746,7 @@ func (dih DeleteInstanceHandler) Handle(c echo.Context) error { } // if caller attempt to set IsRepairTenant then it must be a tenant with targetedInstanceCreation capability if apiRequest.IsRepairTenant != nil && *apiRequest.IsRepairTenant { - if instance.Tenant.Config == nil || !instance.Tenant.Config.TargetedInstanceCreation { + if !common.TenantHasTargetedInstanceCreation(instance.Tenant) { logger.Warn().Msg("tenant does not have capability to set IsRepairTenant") return cutil.NewAPIError(http.StatusForbidden, "Tenant does not have capability to set IsRepairTenant", nil) } diff --git a/api/pkg/api/handler/machine.go b/api/pkg/api/handler/machine.go index d5701f0e6..569d51fbf 100644 --- a/api/pkg/api/handler/machine.go +++ b/api/pkg/api/handler/machine.go @@ -241,7 +241,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{ @@ -614,7 +614,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{ @@ -758,7 +758,7 @@ 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 { + if common.TenantHasTargetedInstanceCreation(tenant) { // Check if privileged Tenant has an account with Infrastructure Provider taDAO := cdbm.NewTenantAccountDAO(umh.dbSession) _, taCount, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ diff --git a/api/pkg/api/handler/site.go b/api/pkg/api/handler/site.go index e52c35965..d125cd316 100644 --- a/api/pkg/api/handler/site.go +++ b/api/pkg/api/handler/site.go @@ -649,7 +649,7 @@ func (gsh GetSiteHandler) Handle(c echo.Context) error { if !isAssociated { // Check if Tenant is privileged - if tenant.Config != nil && tenant.Config.TargetedInstanceCreation { + if common.TenantHasTargetedInstanceCreation(tenant) { taDAO := cdbm.NewTenantAccountDAO(gsh.dbSession) tas, _, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ InfrastructureProviderID: &st.InfrastructureProviderID, @@ -894,7 +894,7 @@ func (gash GetAllSiteHandler) Handle(c echo.Context) error { // If Tenant is privileged (has TargetedInstanceCreation capability), // also retrieve all Sites from Providers they have a Tenant Account with - if tenant.Config != nil && tenant.Config.TargetedInstanceCreation { + if common.TenantHasTargetedInstanceCreation(tenant) { taDAO := cdbm.NewTenantAccountDAO(gash.dbSession) tas, _, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ TenantIDs: []uuid.UUID{tenant.ID}, @@ -1224,7 +1224,7 @@ func (gssdh GetSiteStatusDetailsHandler) Handle(c echo.Context) error { if !isAssociated { // Check if Tenant is privileged - if tenant.Config != nil && tenant.Config.TargetedInstanceCreation { + if common.TenantHasTargetedInstanceCreation(tenant) { taDAO := cdbm.NewTenantAccountDAO(gssdh.dbSession) tas, _, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ InfrastructureProviderID: &st.InfrastructureProviderID, diff --git a/api/pkg/api/handler/sku.go b/api/pkg/api/handler/sku.go index 568f3fc39..36810216d 100644 --- a/api/pkg/api/handler/sku.go +++ b/api/pkg/api/handler/sku.go @@ -105,7 +105,7 @@ func (gash GetAllSkuHandler) Handle(c echo.Context) error { } } else if tenant != nil { // Check if Tenant is privileged - if !tenant.Config.TargetedInstanceCreation { + if !common.TenantHasTargetedInstanceCreation(tenant) { logger.Warn().Msg("Tenant doesn't have targeted Instance creation capability, access denied") return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant must have targeted Instance creation capability in order to retrieve SKUs", nil) } @@ -274,7 +274,7 @@ func (gsh GetSkuHandler) Handle(c echo.Context) error { } } else if tenant != nil { // Check if Tenant is privileged - if !tenant.Config.TargetedInstanceCreation { + if !common.TenantHasTargetedInstanceCreation(tenant) { logger.Warn().Msg("Tenant doesn't have targeted Instance creation capability, access denied") return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant must have targeted Instance creation capability in order to retrieve SKU", nil) } diff --git a/api/pkg/api/handler/util/common/common.go b/api/pkg/api/handler/util/common/common.go index eb50a7d82..7e052fbad 100644 --- a/api/pkg/api/handler/util/common/common.go +++ b/api/pkg/api/handler/util/common/common.go @@ -1279,13 +1279,26 @@ 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 +// "ceiling"). It is nil-safe so callers don't have to repeat the +// tenant/Config nil checks. +// +// This is the single chokepoint for the tenant-level capability read. A +// later phase will introduce a site-scoped EffectiveTargetedInstanceCreation +// that composes this ceiling with per-site association rows and the provider +// gate; until then this preserves the existing tenant-global behavior. +func TenantHasTargetedInstanceCreation(tenant *cdbm.Tenant) bool { + return tenant != nil && tenant.Config != nil && tenant.Config.TargetedInstanceCreation +} + // 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) { @@ -1337,7 +1350,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/api/pkg/api/handler/util/common/common_test.go b/api/pkg/api/handler/util/common/common_test.go index 7578e65af..f9cf57ac0 100644 --- a/api/pkg/api/handler/util/common/common_test.go +++ b/api/pkg/api/handler/util/common/common_test.go @@ -2595,3 +2595,38 @@ func TestQueryTagsFor(t *testing.T) { tags3 := QueryTagsFor(&withTags{}) assert.ElementsMatch(t, []string{"alpha", "beta"}, tags3) } + +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: "config with capability disabled", + tenant: &cdbm.Tenant{Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}}, + expected: false, + }, + { + name: "config with capability 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)) + }) + } +} diff --git a/api/pkg/api/handler/vpc.go b/api/pkg/api/handler/vpc.go index e93e53a75..0f39d6ed2 100644 --- a/api/pkg/api/handler/vpc.go +++ b/api/pkg/api/handler/vpc.go @@ -239,7 +239,7 @@ func (cvh CreateVPCHandler) Handle(c echo.Context) error { if apiRequest.RoutingProfile != nil { // For now, we gate on TargetedInstanceCreation permission, // Which implies a "privileged tenant" - if tenant.Config == nil || !tenant.Config.TargetedInstanceCreation { + if !common.TenantHasTargetedInstanceCreation(tenant) { 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) } From 2707d633f74e1596cbaf0d76d57eaa73dcd39c09 Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Fri, 29 May 2026 16:44:40 -0700 Subject: [PATCH 2/3] feat: add tenant_site_capability_association table, DAO, and conservative backfill migration Signed-off-by: Hitesh Wadekar --- .../model/tenantsitecapabilityassociation.go | 361 +++++++++ .../tenantsitecapabilityassociation_test.go | 748 ++++++++++++++++++ db/pkg/db/model/testing.go | 19 + ...0000_tenant_site_capability_association.go | 115 +++ db/pkg/migrations/migrations_test.go | 91 +++ 5 files changed, 1334 insertions(+) create mode 100644 db/pkg/db/model/tenantsitecapabilityassociation.go create mode 100644 db/pkg/db/model/tenantsitecapabilityassociation_test.go create mode 100644 db/pkg/migrations/20260529160000_tenant_site_capability_association.go diff --git a/db/pkg/db/model/tenantsitecapabilityassociation.go b/db/pkg/db/model/tenantsitecapabilityassociation.go new file mode 100644 index 000000000..733e09a95 --- /dev/null +++ b/db/pkg/db/model/tenantsitecapabilityassociation.go @@ -0,0 +1,361 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "database/sql" + "time" + + "github.com/NVIDIA/infra-controller-rest/db/pkg/db" + "github.com/NVIDIA/infra-controller-rest/db/pkg/db/paginator" + stracer "github.com/NVIDIA/infra-controller-rest/db/pkg/tracer" + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +const ( + // TenantSiteCapabilityAssociationOrderByDefault default field to be used for ordering when none specified + TenantSiteCapabilityAssociationOrderByDefault = "created" +) + +var ( + // TenantSiteCapabilityAssociationOrderByFields is a list of valid order by fields for the TenantSiteCapabilityAssociation model + TenantSiteCapabilityAssociationOrderByFields = []string{"created", "updated"} + // TenantSiteCapabilityAssociationRelatedEntities is a list of valid relation by fields for the TenantSiteCapabilityAssociation model + TenantSiteCapabilityAssociationRelatedEntities = map[string]bool{ + TenantRelationName: true, + SiteRelationName: true, + InfrastructureProviderRelationName: true, + } +) + +// TenantSiteCapabilityAssociation stores per-site capability flags for a Tenant. +// One row per (tenant_id, site_id); PATCH handlers create or update rows when the +// Tenant enables or disables a capability for a resolved set of Sites. See the +// Enhancing-Tenant-Capabilities HLD. +// +// InfrastructureProviderID is denormalized from Site (each Site has exactly one +// Infrastructure Provider) so bulk queries scoped to a Provider don't have to +// join through site; it must stay consistent with the Site's provider. +type TenantSiteCapabilityAssociation struct { + bun.BaseModel `bun:"table:tenant_site_capability_association,alias:tsca"` + + 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"` + SiteID uuid.UUID `bun:"site_id,type:uuid,notnull"` + Site *Site `bun:"rel:belongs-to,join:site_id=id"` + InfrastructureProviderID uuid.UUID `bun:"infrastructure_provider_id,type:uuid,notnull"` + InfrastructureProvider *InfrastructureProvider `bun:"rel:belongs-to,join:infrastructure_provider_id=id"` + TargetedInstanceCreation bool `bun:"targeted_instance_creation,notnull"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` + Deleted *time.Time `bun:"deleted,soft_delete"` + CreatedBy uuid.UUID `bun:"type:uuid,notnull"` +} + +// TenantSiteCapabilityAssociationCreateInput input parameters for Create method +type TenantSiteCapabilityAssociationCreateInput struct { + TenantID uuid.UUID + SiteID uuid.UUID + InfrastructureProviderID uuid.UUID + TargetedInstanceCreation bool + CreatedBy uuid.UUID +} + +// TenantSiteCapabilityAssociationUpdateInput input parameters for Update method +type TenantSiteCapabilityAssociationUpdateInput struct { + TenantSiteCapabilityAssociationID uuid.UUID + TargetedInstanceCreation *bool + InfrastructureProviderID *uuid.UUID +} + +// TenantSiteCapabilityAssociationFilterInput filtering options for GetAll method +type TenantSiteCapabilityAssociationFilterInput struct { + TenantIDs []uuid.UUID + SiteIDs []uuid.UUID + InfrastructureProviderIDs []uuid.UUID +} + +var _ bun.BeforeAppendModelHook = (*TenantSiteCapabilityAssociation)(nil) + +// BeforeAppendModel is a hook that is called before the model is appended to the query +func (tsca *TenantSiteCapabilityAssociation) BeforeAppendModel(_ context.Context, query bun.Query) error { + switch query.(type) { + case *bun.InsertQuery: + tsca.Created = db.GetCurTime() + tsca.Updated = db.GetCurTime() + case *bun.UpdateQuery: + tsca.Updated = db.GetCurTime() + } + return nil +} + +var _ bun.BeforeCreateTableHook = (*TenantSiteCapabilityAssociation)(nil) + +// BeforeCreateTable is a hook that is called before the table is created +func (tsca *TenantSiteCapabilityAssociation) BeforeCreateTable(_ context.Context, query *bun.CreateTableQuery) error { + query.ForeignKey(`("tenant_id") REFERENCES "tenant" ("id")`). + ForeignKey(`("site_id") REFERENCES "site" ("id")`). + ForeignKey(`("infrastructure_provider_id") REFERENCES "infrastructure_provider" ("id")`). + ForeignKey(`("created_by") REFERENCES "user" ("id")`) + return nil +} + +// TenantSiteCapabilityAssociationDAO is an interface for interacting with the TenantSiteCapabilityAssociation model +type TenantSiteCapabilityAssociationDAO interface { + // + GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*TenantSiteCapabilityAssociation, error) + // + GetByTenantIDAndSiteID(ctx context.Context, tx *db.Tx, tenantID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*TenantSiteCapabilityAssociation, error) + // + GetAll(ctx context.Context, tx *db.Tx, filter TenantSiteCapabilityAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]TenantSiteCapabilityAssociation, int, error) + // + Create(ctx context.Context, tx *db.Tx, input TenantSiteCapabilityAssociationCreateInput) (*TenantSiteCapabilityAssociation, error) + // + Update(ctx context.Context, tx *db.Tx, input TenantSiteCapabilityAssociationUpdateInput) (*TenantSiteCapabilityAssociation, error) + // + Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error +} + +// TenantSiteCapabilityAssociationSQLDAO is an implementation of the TenantSiteCapabilityAssociationDAO interface +type TenantSiteCapabilityAssociationSQLDAO struct { + dbSession *db.Session + tracerSpan *stracer.TracerSpan +} + +// GetByID returns a TenantSiteCapabilityAssociation by ID +func (tscad TenantSiteCapabilityAssociationSQLDAO) GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*TenantSiteCapabilityAssociation, error) { + // Create a child span and set the attributes for current request + ctx, tscaDAOSpan := tscad.tracerSpan.CreateChildInCurrentContext(ctx, "TenantSiteCapabilityAssociationDAO.GetByID") + if tscaDAOSpan != nil { + defer tscaDAOSpan.End() + + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "id", id.String()) + } + + tsca := &TenantSiteCapabilityAssociation{} + + query := db.GetIDB(tx, tscad.dbSession).NewSelect().Model(tsca).Where("tsca.id = ?", id) + + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + err := query.Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return tsca, nil +} + +// GetByTenantIDAndSiteID returns a TenantSiteCapabilityAssociation by Tenant ID and Site ID. +// Returns db.ErrDoesNotExist when no (non-deleted) row exists for the pair. +func (tscad TenantSiteCapabilityAssociationSQLDAO) GetByTenantIDAndSiteID(ctx context.Context, tx *db.Tx, tenantID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*TenantSiteCapabilityAssociation, error) { + // Create a child span and set the attributes for current request + ctx, tscaDAOSpan := tscad.tracerSpan.CreateChildInCurrentContext(ctx, "TenantSiteCapabilityAssociationDAO.GetByTenantIDAndSiteID") + if tscaDAOSpan != nil { + defer tscaDAOSpan.End() + + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "tenant_id", tenantID.String()) + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "site_id", siteID.String()) + } + + tsca := &TenantSiteCapabilityAssociation{} + + query := db.GetIDB(tx, tscad.dbSession).NewSelect().Model(tsca). + Where("tsca.tenant_id = ?", tenantID). + Where("tsca.site_id = ?", siteID) + + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + err := query.Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return tsca, nil +} + +// GetAll returns a list of TenantSiteCapabilityAssociations filtered by tenantIDs, siteIDs, infrastructureProviderIDs, offset, limit and orderBy +// if orderBy is nil, then records are ordered by column specified in TenantSiteCapabilityAssociationOrderByDefault in ascending order +func (tscad TenantSiteCapabilityAssociationSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter TenantSiteCapabilityAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]TenantSiteCapabilityAssociation, int, error) { + // Create a child span and set the attributes for current request + ctx, tscaDAOSpan := tscad.tracerSpan.CreateChildInCurrentContext(ctx, "TenantSiteCapabilityAssociationDAO.GetAll") + if tscaDAOSpan != nil { + defer tscaDAOSpan.End() + } + + tscas := []TenantSiteCapabilityAssociation{} + + query := db.GetIDB(tx, tscad.dbSession).NewSelect().Model(&tscas) + + if filter.TenantIDs != nil { + query = query.Where("tsca.tenant_id IN (?)", bun.In(filter.TenantIDs)) + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "tenant_id", filter.TenantIDs) + } + + if filter.SiteIDs != nil { + query = query.Where("tsca.site_id IN (?)", bun.In(filter.SiteIDs)) + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "site_id", filter.SiteIDs) + } + + if filter.InfrastructureProviderIDs != nil { + query = query.Where("tsca.infrastructure_provider_id IN (?)", bun.In(filter.InfrastructureProviderIDs)) + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "infrastructure_provider_id", filter.InfrastructureProviderIDs) + } + + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + // if no order is passed, set default to make sure objects return always in the same order and pagination works properly + if page.OrderBy == nil { + page.OrderBy = paginator.NewDefaultOrderBy(TenantSiteCapabilityAssociationOrderByDefault) + } + + paginator, err := paginator.NewPaginator(ctx, query, page.Offset, page.Limit, page.OrderBy, TenantSiteCapabilityAssociationOrderByFields) + if err != nil { + return nil, 0, err + } + + err = paginator.Query.Limit(paginator.Limit).Offset(paginator.Offset).Scan(ctx) + if err != nil { + return nil, 0, err + } + + return tscas, paginator.Total, nil +} + +// Create creates a new TenantSiteCapabilityAssociation from the given parameters +func (tscad TenantSiteCapabilityAssociationSQLDAO) Create(ctx context.Context, tx *db.Tx, input TenantSiteCapabilityAssociationCreateInput) (*TenantSiteCapabilityAssociation, error) { + // Create a child span and set the attributes for current request + ctx, tscaDAOSpan := tscad.tracerSpan.CreateChildInCurrentContext(ctx, "TenantSiteCapabilityAssociationDAO.Create") + if tscaDAOSpan != nil { + defer tscaDAOSpan.End() + + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "tenant_id", input.TenantID.String()) + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "site_id", input.SiteID.String()) + } + + tsca := &TenantSiteCapabilityAssociation{ + ID: uuid.New(), + TenantID: input.TenantID, + SiteID: input.SiteID, + InfrastructureProviderID: input.InfrastructureProviderID, + TargetedInstanceCreation: input.TargetedInstanceCreation, + CreatedBy: input.CreatedBy, + } + + _, err := db.GetIDB(tx, tscad.dbSession).NewInsert().Model(tsca).Exec(ctx) + if err != nil { + return nil, err + } + + ntsca, err := tscad.GetByID(ctx, tx, tsca.ID, nil) + if err != nil { + return nil, err + } + + return ntsca, nil +} + +// Update updates an existing TenantSiteCapabilityAssociation from the given parameters +func (tscad TenantSiteCapabilityAssociationSQLDAO) Update(ctx context.Context, tx *db.Tx, input TenantSiteCapabilityAssociationUpdateInput) (*TenantSiteCapabilityAssociation, error) { + // Create a child span and set the attributes for current request + ctx, tscaDAOSpan := tscad.tracerSpan.CreateChildInCurrentContext(ctx, "TenantSiteCapabilityAssociationDAO.Update") + if tscaDAOSpan != nil { + defer tscaDAOSpan.End() + + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "id", input.TenantSiteCapabilityAssociationID.String()) + } + + tsca := &TenantSiteCapabilityAssociation{ + ID: input.TenantSiteCapabilityAssociationID, + } + + updatedFields := []string{} + + if input.TargetedInstanceCreation != nil { + tsca.TargetedInstanceCreation = *input.TargetedInstanceCreation + updatedFields = append(updatedFields, "targeted_instance_creation") + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "targeted_instance_creation", *input.TargetedInstanceCreation) + } + + if input.InfrastructureProviderID != nil { + tsca.InfrastructureProviderID = *input.InfrastructureProviderID + updatedFields = append(updatedFields, "infrastructure_provider_id") + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "infrastructure_provider_id", input.InfrastructureProviderID.String()) + } + + if len(updatedFields) > 0 { + updatedFields = append(updatedFields, "updated") + + _, err := db.GetIDB(tx, tscad.dbSession).NewUpdate().Model(tsca).Column(updatedFields...).Where("id = ?", input.TenantSiteCapabilityAssociationID).Exec(ctx) + if err != nil { + return nil, err + } + } + + utsca, err := tscad.GetByID(ctx, tx, input.TenantSiteCapabilityAssociationID, nil) + if err != nil { + return nil, err + } + + return utsca, nil +} + +// Delete deletes a TenantSiteCapabilityAssociation by ID +func (tscad TenantSiteCapabilityAssociationSQLDAO) Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error { + // Create a child span and set the attributes for current request + ctx, tscaDAOSpan := tscad.tracerSpan.CreateChildInCurrentContext(ctx, "TenantSiteCapabilityAssociationDAO.Delete") + if tscaDAOSpan != nil { + defer tscaDAOSpan.End() + + tscad.tracerSpan.SetAttribute(tscaDAOSpan, "id", id.String()) + } + + tsca := &TenantSiteCapabilityAssociation{ + ID: id, + } + + _, err := db.GetIDB(tx, tscad.dbSession).NewDelete().Model(tsca).Where("id = ?", id).Exec(ctx) + if err != nil { + return err + } + + return nil +} + +// NewTenantSiteCapabilityAssociationDAO creates a new TenantSiteCapabilityAssociationDAO +func NewTenantSiteCapabilityAssociationDAO(dbSession *db.Session) TenantSiteCapabilityAssociationDAO { + return &TenantSiteCapabilityAssociationSQLDAO{ + dbSession: dbSession, + tracerSpan: stracer.NewTracerSpan(), + } +} diff --git a/db/pkg/db/model/tenantsitecapabilityassociation_test.go b/db/pkg/db/model/tenantsitecapabilityassociation_test.go new file mode 100644 index 000000000..45a5debcf --- /dev/null +++ b/db/pkg/db/model/tenantsitecapabilityassociation_test.go @@ -0,0 +1,748 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "fmt" + "testing" + + "github.com/NVIDIA/infra-controller-rest/common/pkg/roles" + "github.com/NVIDIA/infra-controller-rest/db/pkg/db" + "github.com/NVIDIA/infra-controller-rest/db/pkg/db/paginator" + stracer "github.com/NVIDIA/infra-controller-rest/db/pkg/tracer" + "github.com/NVIDIA/infra-controller-rest/db/pkg/util" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + otrace "go.opentelemetry.io/otel/trace" +) + +func TestNewTenantSiteCapabilityAssociationDAO(t *testing.T) { + dbSession := &db.Session{} + + type args struct { + dbSession *db.Session + } + tests := []struct { + name string + args args + want TenantSiteCapabilityAssociationDAO + }{ + { + name: "test Tenant Site Capability Association DAO initialization", + args: args{ + dbSession: dbSession, + }, + want: &TenantSiteCapabilityAssociationSQLDAO{ + dbSession: dbSession, + tracerSpan: stracer.NewTracerSpan(), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewTenantSiteCapabilityAssociationDAO(tt.args.dbSession) + assert.Equal(t, got, tt.want) + }) + } +} + +func TestTenantSiteCapabilityAssociationSQLDAO_GetByID(t *testing.T) { + ctx := context.Background() + dbSession := util.TestInitDB(t) + defer dbSession.Close() + + TestSetupSchema(t, dbSession) + + // Create initial data + ipOrg := "test-provider-org" + ipRoles := []string{roles.ProviderAdminRole} + tnOrg := "test-tenant-org" + tnRoles := []string{roles.TenantAdminRole} + ipu := TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, ipRoles) + ip := TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipu) + + tnu := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, tnRoles) + tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) + + site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) + tsca := TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, site, true, tnu) + + type fields struct { + dbSession *db.Session + } + type args struct { + ctx context.Context + tx *db.Tx + id uuid.UUID + includeRelations []string + } + + // OTEL Spanner configuration + _, _, ctx = testCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + fields fields + args args + want *TenantSiteCapabilityAssociation + wantErr bool + verifyChildSpanner bool + }{ + { + name: "test get tenant site capability association by ID", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + ctx: ctx, + tx: nil, + id: tsca.ID, + }, + want: tsca, + verifyChildSpanner: true, + }, + { + name: "test get tenant site capability association by ID with relations", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + ctx: ctx, + tx: nil, + id: tsca.ID, + includeRelations: []string{ + TenantRelationName, + SiteRelationName, + InfrastructureProviderRelationName, + }, + }, + want: tsca, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tscad := TenantSiteCapabilityAssociationSQLDAO{ + dbSession: tt.fields.dbSession, + } + got, err := tscad.GetByID(tt.args.ctx, tt.args.tx, tt.args.id, tt.args.includeRelations) + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + assert.Equal(t, tt.want.ID, got.ID) + assert.Equal(t, tt.want.TenantID, got.TenantID) + assert.Equal(t, tt.want.SiteID, got.SiteID) + assert.Equal(t, tt.want.InfrastructureProviderID, got.InfrastructureProviderID) + assert.Equal(t, tt.want.TargetedInstanceCreation, got.TargetedInstanceCreation) + + if tt.args.includeRelations != nil { + assert.NotNil(t, got.Tenant) + assert.NotNil(t, got.Site) + assert.NotNil(t, got.InfrastructureProvider) + } + + if tt.verifyChildSpanner { + span := otrace.SpanFromContext(ctx) + assert.True(t, span.SpanContext().IsValid()) + _, ok := ctx.Value(stracer.TracerKey).(otrace.Tracer) + assert.True(t, ok) + } + }) + } +} + +func TestTenantSiteCapabilityAssociationSQLDAO_GetByTenantIDAndSiteID(t *testing.T) { + ctx := context.Background() + dbSession := util.TestInitDB(t) + defer dbSession.Close() + + TestSetupSchema(t, dbSession) + + // Create initial data + ipOrg := "test-provider-org" + ipRoles := []string{roles.ProviderAdminRole} + tnOrg := "test-tenant-org" + tnRoles := []string{roles.TenantAdminRole} + ipu := TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, ipRoles) + ip := TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipu) + + tnu := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, tnRoles) + tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) + + site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) + tsca := TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, site, true, tnu) + + type fields struct { + dbSession *db.Session + } + type args struct { + ctx context.Context + tx *db.Tx + tenantID uuid.UUID + siteID uuid.UUID + includeRelations []string + } + + // OTEL Spanner configuration + _, _, ctx = testCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + fields fields + args args + want *TenantSiteCapabilityAssociation + wantErr bool + verifyChildSpanner bool + }{ + { + name: "test get TenantSiteCapabilityAssociation by Tenant ID and Site ID", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + ctx: ctx, + tx: nil, + tenantID: tn.ID, + siteID: site.ID, + }, + want: tsca, + verifyChildSpanner: true, + }, + { + name: "test get TenantSiteCapabilityAssociation by Tenant ID and Site ID with relations", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + ctx: ctx, + tx: nil, + tenantID: tn.ID, + siteID: site.ID, + includeRelations: []string{ + TenantRelationName, + SiteRelationName, + InfrastructureProviderRelationName, + }, + }, + want: tsca, + }, + { + name: "test get TenantSiteCapabilityAssociation that does not exist", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + ctx: ctx, + tx: nil, + tenantID: tn.ID, + siteID: uuid.New(), + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tscad := TenantSiteCapabilityAssociationSQLDAO{ + dbSession: tt.fields.dbSession, + } + got, err := tscad.GetByTenantIDAndSiteID(tt.args.ctx, tt.args.tx, tt.args.tenantID, tt.args.siteID, tt.args.includeRelations) + if tt.wantErr { + assert.ErrorIs(t, err, db.ErrDoesNotExist) + return + } + + assert.NoError(t, err) + + assert.Equal(t, tt.want.ID, got.ID) + assert.Equal(t, tt.want.TenantID, got.TenantID) + assert.Equal(t, tt.want.SiteID, got.SiteID) + + if tt.args.includeRelations != nil { + assert.NotNil(t, got.Tenant) + assert.NotNil(t, got.Site) + assert.NotNil(t, got.InfrastructureProvider) + } + + if tt.verifyChildSpanner { + span := otrace.SpanFromContext(ctx) + assert.True(t, span.SpanContext().IsValid()) + _, ok := ctx.Value(stracer.TracerKey).(otrace.Tracer) + assert.True(t, ok) + } + }) + } +} + +func TestTenantSiteCapabilityAssociationSQLDAO_GetAll(t *testing.T) { + ctx := context.Background() + dbSession := util.TestInitDB(t) + defer dbSession.Close() + + TestSetupSchema(t, dbSession) + + // Create initial data + ipOrg := "test-provider-org" + ipRoles := []string{roles.ProviderAdminRole} + tnOrg1 := "test-tenant-org-1" + tnOrg2 := "test-tenant-org-2" + tnRoles := []string{roles.TenantAdminRole} + ipu := TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, ipRoles) + ip := TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipu) + + tnu1 := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg1, tnRoles) + tn1 := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg1, tnu1) + + tnu2 := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg2, tnRoles) + tn2 := TestBuildTenant(t, dbSession, "Test Tenant 2", tnOrg2, tnu2) + + sites := []*Site{} + siteCount := 30 + for i := 0; i < siteCount; i++ { + site := TestBuildSite(t, dbSession, ip, fmt.Sprintf("test-site-%d", i), ipu) + sites = append(sites, site) + if i%2 == 0 { + TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn1, site, true, tnu1) + } else { + TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn2, site, false, tnu2) + } + } + + type fields struct { + dbSession *db.Session + } + type args struct { + tenantIDs []uuid.UUID + siteIDs []uuid.UUID + infrastructureProviderIDs []uuid.UUID + includeRelations []string + offset *int + limit *int + orderBy *paginator.OrderBy + } + + // OTEL Spanner configuration + _, _, ctx = testCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + fields fields + args args + wantCount int + wantTotalCount int + verifyChildSpanner bool + }{ + { + name: "test get all, no filter", + fields: fields{ + dbSession: dbSession, + }, + args: args{}, + wantCount: paginator.DefaultLimit, + wantTotalCount: siteCount, + verifyChildSpanner: true, + }, + { + name: "test get all, filter by tenant ID", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + tenantIDs: []uuid.UUID{tn1.ID}, + }, + wantCount: siteCount / 2, + wantTotalCount: siteCount / 2, + }, + { + name: "test get all, filter by multiple tenant IDs", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + tenantIDs: []uuid.UUID{tn1.ID, tn2.ID}, + }, + wantCount: paginator.DefaultLimit, + wantTotalCount: siteCount, + }, + { + name: "test get all, filter by site ID", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + siteIDs: []uuid.UUID{sites[0].ID}, + }, + wantCount: 1, + wantTotalCount: 1, + }, + { + name: "test get all, filter by multiple site IDs", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + siteIDs: []uuid.UUID{sites[0].ID, sites[1].ID}, + }, + wantCount: 2, + wantTotalCount: 2, + }, + { + name: "test get all, filter by infrastructure provider ID", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + infrastructureProviderIDs: []uuid.UUID{ip.ID}, + }, + wantCount: paginator.DefaultLimit, + wantTotalCount: siteCount, + }, + { + name: "test get all, with limit", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + limit: db.GetIntPtr(10), + }, + wantCount: 10, + wantTotalCount: siteCount, + }, + { + name: "test get all, with offset", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + offset: db.GetIntPtr(10), + }, + wantCount: paginator.DefaultLimit, + wantTotalCount: siteCount, + }, + { + name: "test get all, with order by", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + orderBy: &paginator.OrderBy{ + Field: "created", + Order: paginator.OrderDescending, + }, + }, + wantCount: paginator.DefaultLimit, + wantTotalCount: siteCount, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tscad := TenantSiteCapabilityAssociationSQLDAO{ + dbSession: tt.fields.dbSession, + } + filter := TenantSiteCapabilityAssociationFilterInput{ + TenantIDs: tt.args.tenantIDs, + SiteIDs: tt.args.siteIDs, + InfrastructureProviderIDs: tt.args.infrastructureProviderIDs, + } + page := paginator.PageInput{ + Limit: tt.args.limit, + Offset: tt.args.offset, + OrderBy: tt.args.orderBy, + } + got, count, err := tscad.GetAll(ctx, nil, filter, page, tt.args.includeRelations) + + assert.NoError(t, err) + assert.Equal(t, tt.wantCount, len(got)) + assert.Equal(t, tt.wantTotalCount, count) + + if tt.args.orderBy != nil { + assert.Equal(t, sites[siteCount-1].ID, got[0].SiteID) + } + + if tt.verifyChildSpanner { + span := otrace.SpanFromContext(ctx) + assert.True(t, span.SpanContext().IsValid()) + _, ok := ctx.Value(stracer.TracerKey).(otrace.Tracer) + assert.True(t, ok) + } + }) + } +} + +func TestTenantSiteCapabilityAssociationSQLDAO_Create(t *testing.T) { + ctx := context.Background() + dbSession := util.TestInitDB(t) + defer dbSession.Close() + + TestSetupSchema(t, dbSession) + + // Create initial data + ipOrg := "test-provider-org" + ipRoles := []string{roles.ProviderAdminRole} + tnOrg := "test-tenant-org" + tnRoles := []string{roles.TenantAdminRole} + ipu := TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, ipRoles) + ip := TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipu) + + tnu := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, tnRoles) + tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) + + site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) + + type fields struct { + dbSession *db.Session + } + type args struct { + tenantID uuid.UUID + siteID uuid.UUID + infrastructureProviderID uuid.UUID + targetedInstanceCreation bool + createdBy uuid.UUID + } + + // OTEL Spanner configuration + _, _, ctx = testCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + fields fields + args args + want *TenantSiteCapabilityAssociation + verifyChildSpanner bool + }{ + { + name: "test create tenant site capability association", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + tenantID: tn.ID, + siteID: site.ID, + infrastructureProviderID: ip.ID, + targetedInstanceCreation: true, + createdBy: tnu.ID, + }, + want: &TenantSiteCapabilityAssociation{ + TenantID: tn.ID, + SiteID: site.ID, + InfrastructureProviderID: ip.ID, + TargetedInstanceCreation: true, + CreatedBy: tnu.ID, + }, + verifyChildSpanner: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tscad := TenantSiteCapabilityAssociationSQLDAO{ + dbSession: tt.fields.dbSession, + } + input := TenantSiteCapabilityAssociationCreateInput{ + TenantID: tt.args.tenantID, + SiteID: tt.args.siteID, + InfrastructureProviderID: tt.args.infrastructureProviderID, + TargetedInstanceCreation: tt.args.targetedInstanceCreation, + CreatedBy: tt.args.createdBy, + } + got, err := tscad.Create(ctx, nil, input) + assert.NoError(t, err) + + assert.NotNil(t, got.ID) + assert.Equal(t, tt.want.TenantID, got.TenantID) + assert.Equal(t, tt.want.SiteID, got.SiteID) + assert.Equal(t, tt.want.InfrastructureProviderID, got.InfrastructureProviderID) + assert.Equal(t, tt.want.TargetedInstanceCreation, got.TargetedInstanceCreation) + assert.Equal(t, tt.want.CreatedBy, got.CreatedBy) + + if tt.verifyChildSpanner { + span := otrace.SpanFromContext(ctx) + assert.True(t, span.SpanContext().IsValid()) + _, ok := ctx.Value(stracer.TracerKey).(otrace.Tracer) + assert.True(t, ok) + } + }) + } +} + +func TestTenantSiteCapabilityAssociationSQLDAO_Update(t *testing.T) { + ctx := context.Background() + dbSession := util.TestInitDB(t) + defer dbSession.Close() + + TestSetupSchema(t, dbSession) + + // Create initial data + ipOrg := "test-provider-org" + ipRoles := []string{roles.ProviderAdminRole} + tnOrg := "test-tenant-org" + tnRoles := []string{roles.TenantAdminRole} + ipu := TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, ipRoles) + ip := TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipu) + + tnu := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, tnRoles) + tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) + + site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) + tsca := TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, site, true, tnu) + + type fields struct { + dbSession *db.Session + } + type args struct { + id uuid.UUID + targetedInstanceCreation *bool + } + + // OTEL Spanner configuration + _, _, ctx = testCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + fields fields + args args + want *TenantSiteCapabilityAssociation + verifyChildSpanner bool + }{ + { + name: "test update tenant site capability association, disable capability", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + id: tsca.ID, + targetedInstanceCreation: db.GetBoolPtr(false), + }, + want: &TenantSiteCapabilityAssociation{ + ID: tsca.ID, + TargetedInstanceCreation: false, + }, + verifyChildSpanner: true, + }, + { + name: "test update tenant site capability association, enable capability", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + id: tsca.ID, + targetedInstanceCreation: db.GetBoolPtr(true), + }, + want: &TenantSiteCapabilityAssociation{ + ID: tsca.ID, + TargetedInstanceCreation: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tscad := TenantSiteCapabilityAssociationSQLDAO{ + dbSession: tt.fields.dbSession, + } + input := TenantSiteCapabilityAssociationUpdateInput{ + TenantSiteCapabilityAssociationID: tt.args.id, + TargetedInstanceCreation: tt.args.targetedInstanceCreation, + } + got, err := tscad.Update(ctx, nil, input) + assert.NoError(t, err) + + if tt.args.targetedInstanceCreation != nil { + assert.Equal(t, tt.want.TargetedInstanceCreation, got.TargetedInstanceCreation) + } + + if tt.verifyChildSpanner { + span := otrace.SpanFromContext(ctx) + assert.True(t, span.SpanContext().IsValid()) + _, ok := ctx.Value(stracer.TracerKey).(otrace.Tracer) + assert.True(t, ok) + } + }) + } +} + +func TestTenantSiteCapabilityAssociationSQLDAO_Delete(t *testing.T) { + ctx := context.Background() + dbSession := util.TestInitDB(t) + defer dbSession.Close() + + TestSetupSchema(t, dbSession) + + // Create initial data + ipOrg := "test-provider-org" + ipRoles := []string{roles.ProviderAdminRole} + tnOrg := "test-tenant-org" + tnRoles := []string{roles.TenantAdminRole} + ipu := TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, ipRoles) + ip := TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipu) + + tnu := TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, tnRoles) + tn := TestBuildTenant(t, dbSession, "Test Tenant", tnOrg, tnu) + + site := TestBuildSite(t, dbSession, ip, "Test Site 1", ipu) + tsca := TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, site, true, tnu) + + type fields struct { + dbSession *db.Session + } + type args struct { + id uuid.UUID + } + + // OTEL Spanner configuration + _, _, ctx = testCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + fields fields + args args + verifyChildSpanner bool + }{ + { + name: "test delete tenant site capability association", + fields: fields{ + dbSession: dbSession, + }, + args: args{ + id: tsca.ID, + }, + verifyChildSpanner: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tscad := TenantSiteCapabilityAssociationSQLDAO{ + dbSession: tt.fields.dbSession, + } + err := tscad.Delete(ctx, nil, tt.args.id) + assert.NoError(t, err) + + // Check if the tenant site capability association is deleted + _, err = tscad.GetByID(ctx, nil, tt.args.id, nil) + assert.ErrorIs(t, err, db.ErrDoesNotExist) + + if tt.verifyChildSpanner { + span := otrace.SpanFromContext(ctx) + assert.True(t, span.SpanContext().IsValid()) + _, ok := ctx.Value(stracer.TracerKey).(otrace.Tracer) + assert.True(t, ok) + } + }) + } +} diff --git a/db/pkg/db/model/testing.go b/db/pkg/db/model/testing.go index 6991ba03a..4222435da 100644 --- a/db/pkg/db/model/testing.go +++ b/db/pkg/db/model/testing.go @@ -33,6 +33,9 @@ func TestSetupSchema(t *testing.T, dbSession *db.Session) { // create TenantSite table err = dbSession.DB.ResetModel(context.Background(), (*TenantSite)(nil)) assert.Nil(t, err) + // create TenantSiteCapabilityAssociation table + err = dbSession.DB.ResetModel(context.Background(), (*TenantSiteCapabilityAssociation)(nil)) + assert.Nil(t, err) // create Network Security Group table err = dbSession.DB.ResetModel(context.Background(), (*NetworkSecurityGroup)(nil)) assert.Nil(t, err) @@ -223,6 +226,22 @@ func TestBuildTenantSite(t *testing.T, dbSession *db.Session, tn *Tenant, st *Si return ts } +// TestBuildTenantSiteCapabilityAssociation creates a test per-site capability association for a Tenant +func TestBuildTenantSiteCapabilityAssociation(t *testing.T, dbSession *db.Session, tn *Tenant, st *Site, targetedInstanceCreation bool, user *User) *TenantSiteCapabilityAssociation { + tscaDAO := NewTenantSiteCapabilityAssociationDAO(dbSession) + + tsca, err := tscaDAO.Create(context.Background(), nil, TenantSiteCapabilityAssociationCreateInput{ + TenantID: tn.ID, + SiteID: st.ID, + InfrastructureProviderID: st.InfrastructureProviderID, + TargetedInstanceCreation: targetedInstanceCreation, + CreatedBy: user.ID, + }) + assert.Nil(t, err) + + return tsca +} + // TestBuildInstanceType creates a test Instance Type func TestBuildInstanceType(t *testing.T, dbSession *db.Session, name string, ip *InfrastructureProvider, site *Site, user *User) *InstanceType { instanceType := &InstanceType{ diff --git a/db/pkg/migrations/20260529160000_tenant_site_capability_association.go b/db/pkg/migrations/20260529160000_tenant_site_capability_association.go new file mode 100644 index 000000000..1dde8873a --- /dev/null +++ b/db/pkg/migrations/20260529160000_tenant_site_capability_association.go @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/NVIDIA/infra-controller-rest/db/pkg/db/model" + "github.com/uptrace/bun" +) + +// tenantSiteCapabilityAssociationUpMigration creates the +// tenant_site_capability_association table with its indexes and a conservative +// backfill so that tenants that already have the tenant-global +// TargetedInstanceCreation capability keep their current effective behavior. +// +// Backfill (conservative strategy from the HLD): for every Tenant whose +// config.targetedInstanceCreation is true, enable the capability for all of the +// Sites it could implicitly reach today, i.e. the union of: +// - Sites it is explicitly associated with via tenant_site, and +// - Sites belonging to any Infrastructure Provider it has a Ready tenant_account with. +func tenantSiteCapabilityAssociationUpMigration(ctx context.Context, db *bun.DB) error { + // Start transaction + tx, terr := db.BeginTx(ctx, &sql.TxOptions{}) + if terr != nil { + handlePanic(terr, "failed to begin transaction") + } + + // Create tenant_site_capability_association table + _, err := tx.NewCreateTable().Model((*model.TenantSiteCapabilityAssociation)(nil)).IfNotExists().Exec(ctx) + handleError(tx, err) + fmt.Print(" [up migration] Created tenant_site_capability_association table successfully.") + + // At most one active row per (tenant, site). Partial so that a soft-deleted + // row does not block re-creating the association for the same pair. + _, err = tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS tsca_tenant_site_uniq ON tenant_site_capability_association (tenant_id, site_id) WHERE deleted IS NULL") + handleError(tx, err) + fmt.Print(" [up migration] Created unique index tsca_tenant_site_uniq successfully.") + + // List/filter capabilities by tenant + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS tsca_tenant_id_idx ON tenant_site_capability_association (tenant_id)") + handleError(tx, err) + + // Bulk updates and queries "all sites under provider P" for a tenant + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS tsca_infrastructure_provider_id_idx ON tenant_site_capability_association (infrastructure_provider_id)") + handleError(tx, err) + + // Resolve effective capability when handling instance/machine APIs keyed by site + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS tsca_site_id_idx ON tenant_site_capability_association (site_id)") + handleError(tx, err) + fmt.Print(" [up migration] Created supporting indexes on tenant_site_capability_association successfully.") + + // Conservative backfill: preserve existing tenant-global behavior by enabling + // the capability on every Site each privileged Tenant could implicitly reach today. + _, err = tx.ExecContext(ctx, ` + INSERT INTO tenant_site_capability_association + (id, tenant_id, site_id, infrastructure_provider_id, targeted_instance_creation, created, updated, created_by) + SELECT gen_random_uuid(), x.tenant_id, x.site_id, x.infrastructure_provider_id, true, now(), now(), x.created_by + FROM ( + -- Sites the Tenant is explicitly associated with + SELECT t.id AS tenant_id, s.id AS site_id, s.infrastructure_provider_id AS infrastructure_provider_id, t.created_by AS created_by + FROM tenant t + JOIN tenant_site ts ON ts.tenant_id = t.id AND ts.deleted IS NULL + JOIN site s ON s.id = ts.site_id AND s.deleted IS NULL + WHERE t.deleted IS NULL + AND COALESCE((t.config->>'targetedInstanceCreation')::boolean, false) = true + UNION + -- Sites reachable via a Ready tenant_account for the Site's provider + SELECT t.id, s.id, s.infrastructure_provider_id, t.created_by + FROM tenant t + JOIN tenant_account ta ON ta.tenant_id = t.id AND ta.deleted IS NULL AND ta.status = ? + JOIN site s ON s.infrastructure_provider_id = ta.infrastructure_provider_id AND s.deleted IS NULL + WHERE t.deleted IS NULL + AND COALESCE((t.config->>'targetedInstanceCreation')::boolean, false) = true + ) x + ON CONFLICT (tenant_id, site_id) WHERE deleted IS NULL DO NOTHING + `, model.TenantAccountStatusReady) + handleError(tx, err) + fmt.Print(" [up migration] Backfilled tenant_site_capability_association rows for privileged tenants successfully.") + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + return nil +} + +func init() { + Migrations.MustRegister(tenantSiteCapabilityAssociationUpMigration, func(ctx context.Context, db *bun.DB) error { + _, err := db.NewDropTable().Model((*model.TenantSiteCapabilityAssociation)(nil)).IfExists().Exec(ctx) + if err != nil { + return err + } + fmt.Print(" [down migration] Dropped tenant_site_capability_association table.") + return nil + }) +} diff --git a/db/pkg/migrations/migrations_test.go b/db/pkg/migrations/migrations_test.go index 45839c8e5..91e46b1c3 100644 --- a/db/pkg/migrations/migrations_test.go +++ b/db/pkg/migrations/migrations_test.go @@ -648,6 +648,97 @@ func Test_createAndPopulateTenantSiteUpMigrationfunc(t *testing.T) { assert.Equal(t, 2, tot) } +func Test_tenantSiteCapabilityAssociationUpMigration(t *testing.T) { + ctx := context.Background() + + dbSession := util.GetTestDBSession(t, true) + dbSession.DB.AddQueryHook(bundebug.NewQueryHook( + bundebug.WithEnabled(false), + bundebug.FromEnv("BUNDEBUG"), + )) + defer dbSession.Close() + + // Setup schemas + model.TestSetupSchema(t, dbSession) + + // Create initial data + ipOrg := "test-provider-org" + ipRoles := []string{authz.ProviderAdminRole} + ipu := model.TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, ipRoles) + ip := model.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + + tnRoles := []string{authz.TenantAdminRole} + + // Privileged tenant (config.targetedInstanceCreation = true) + tnu1 := model.TestBuildUser(t, dbSession, uuid.NewString(), "test-tenant-org-1", tnRoles) + tn1 := model.TestBuildTenant(t, dbSession, "test-tenant-1", "test-tenant-org-1", tnu1) + tnDAO := model.NewTenantDAO(dbSession) + _, err := tnDAO.UpdateFromParams(ctx, nil, tn1.ID, nil, nil, nil, &model.TenantConfig{TargetedInstanceCreation: true}) + require.NoError(t, err) + + // Non-privileged tenant (no capability) - should not be backfilled + tnu2 := model.TestBuildUser(t, dbSession, uuid.NewString(), "test-tenant-org-2", tnRoles) + tn2 := model.TestBuildTenant(t, dbSession, "test-tenant-2", "test-tenant-org-2", tnu2) + + site1 := model.TestBuildSite(t, dbSession, ip, "test-site-1", ipu) + site2 := model.TestBuildSite(t, dbSession, ip, "test-site-2", ipu) + + // tn1 is explicitly associated with site1 ... + model.TestBuildTenantSite(t, dbSession, tn1, site1, map[string]interface{}{}, tnu1) + // ... and reaches all provider sites (site1, site2) via a Ready tenant account + ta := &model.TenantAccount{ + ID: uuid.New(), + AccountNumber: "acct-1", + TenantID: &tn1.ID, + TenantOrg: tn1.Org, + InfrastructureProviderID: ip.ID, + InfrastructureProviderOrg: ip.Org, + Status: model.TenantAccountStatusReady, + CreatedBy: ipu.ID, + } + _, err = dbSession.DB.NewInsert().Model(ta).Exec(ctx) + require.NoError(t, err) + + // Drop the table so the migration recreates and backfills it from scratch + _, err = dbSession.DB.NewDropTable().IfExists().Model((*model.TenantSiteCapabilityAssociation)(nil)).Exec(ctx) + require.NoError(t, err) + + // Call up migration function + err = tenantSiteCapabilityAssociationUpMigration(ctx, dbSession.DB) + assert.NoError(t, err) + + tscaDAO := model.NewTenantSiteCapabilityAssociationDAO(dbSession) + + // Privileged tenant gets both sites (union of tenant_site and provider-reachable), enabled + tn1Rows, tn1Total, err := tscaDAO.GetAll(ctx, nil, model.TenantSiteCapabilityAssociationFilterInput{ + TenantIDs: []uuid.UUID{tn1.ID}, + }, paginator.PageInput{}, nil) + assert.NoError(t, err) + assert.Equal(t, 2, tn1Total) + for _, r := range tn1Rows { + assert.True(t, r.TargetedInstanceCreation) + assert.Equal(t, ip.ID, r.InfrastructureProviderID) + } + + // Non-privileged tenant gets no rows + _, tn2Total, err := tscaDAO.GetAll(ctx, nil, model.TenantSiteCapabilityAssociationFilterInput{ + TenantIDs: []uuid.UUID{tn2.ID}, + }, paginator.PageInput{}, nil) + assert.NoError(t, err) + assert.Equal(t, 0, tn2Total) + + // Idempotent: running the migration again does not create duplicates + err = tenantSiteCapabilityAssociationUpMigration(ctx, dbSession.DB) + assert.NoError(t, err) + _, tn1TotalAfter, err := tscaDAO.GetAll(ctx, nil, model.TenantSiteCapabilityAssociationFilterInput{ + TenantIDs: []uuid.UUID{tn1.ID}, + }, paginator.PageInput{}, nil) + assert.NoError(t, err) + assert.Equal(t, 2, tn1TotalAfter) + + _ = site2 +} + func Test_siteSshHostnameRenameUpMigration(t *testing.T) { ctx := context.Background() From d0916c6f2901aaea2647269d8d6daa56c76e8595 Mon Sep 17 00:00:00 2001 From: Hitesh Wadekar Date: Mon, 1 Jun 2026 12:00:04 -0700 Subject: [PATCH 3/3] feat: add PATCH tenant capabilities endpoint to scope TargetedInstanceCreation per site Signed-off-by: Hitesh Wadekar --- api/pkg/api/handler/tenantcapability.go | 322 +++++++++++++++++ api/pkg/api/handler/tenantcapability_test.go | 341 ++++++++++++++++++ api/pkg/api/model/tenantcapability.go | 66 ++++ api/pkg/api/routes.go | 6 + api/pkg/api/routes_test.go | 2 +- .../model/tenantsitecapabilityassociation.go | 18 +- .../tenantsitecapabilityassociation_test.go | 18 +- ...0000_tenant_site_capability_association.go | 18 +- flow/internal/task/message/message.go | 18 +- flow/internal/task/operations/summary.go | 18 +- flow/internal/task/operations/summary_test.go | 18 +- flow/internal/task/report/report.go | 18 +- openapi/spec.yaml | 124 +++++++ sdk/standard/api_allocation.go | 1 + sdk/standard/api_audit.go | 1 + sdk/standard/api_dpu_extension_service.go | 1 + sdk/standard/api_expected_machine.go | 1 + sdk/standard/api_expected_power_shelf.go | 1 + sdk/standard/api_expected_rack.go | 1 + sdk/standard/api_expected_switch.go | 1 + sdk/standard/api_infini_band_partition.go | 2 + sdk/standard/api_instance.go | 4 + sdk/standard/api_instance_type.go | 2 + sdk/standard/api_ip_block.go | 2 + sdk/standard/api_machine.go | 2 + sdk/standard/api_network_security_group.go | 1 + sdk/standard/api_nv_link_logical_partition.go | 2 + sdk/standard/api_operating_system.go | 1 + sdk/standard/api_rack.go | 3 + sdk/standard/api_site.go | 1 + sdk/standard/api_sku.go | 1 + sdk/standard/api_ssh_key.go | 1 + sdk/standard/api_ssh_key_group.go | 1 + sdk/standard/api_subnet.go | 1 + sdk/standard/api_tenant.go | 144 ++++++++ sdk/standard/api_tenant_account.go | 1 + sdk/standard/api_tray.go | 3 + sdk/standard/api_vpc.go | 1 + sdk/standard/api_vpc_peering.go | 1 + sdk/standard/api_vpc_prefix.go | 1 + sdk/standard/model_tenant_capability.go | 196 ++++++++++ .../model_tenant_capability_update_request.go | 260 +++++++++++++ 42 files changed, 1512 insertions(+), 113 deletions(-) create mode 100644 api/pkg/api/handler/tenantcapability.go create mode 100644 api/pkg/api/handler/tenantcapability_test.go create mode 100644 api/pkg/api/model/tenantcapability.go create mode 100644 sdk/standard/model_tenant_capability.go create mode 100644 sdk/standard/model_tenant_capability_update_request.go diff --git a/api/pkg/api/handler/tenantcapability.go b/api/pkg/api/handler/tenantcapability.go new file mode 100644 index 000000000..88bee82bb --- /dev/null +++ b/api/pkg/api/handler/tenantcapability.go @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "fmt" + "net/http" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/attribute" + temporalClient "go.temporal.io/sdk/client" + + "github.com/NVIDIA/infra-controller-rest/api/internal/config" + common "github.com/NVIDIA/infra-controller-rest/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller-rest/api/pkg/api/model" + auth "github.com/NVIDIA/infra-controller-rest/auth/pkg/authorization" + cutil "github.com/NVIDIA/infra-controller-rest/common/pkg/util" + cdb "github.com/NVIDIA/infra-controller-rest/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller-rest/db/pkg/db/model" + cdbp "github.com/NVIDIA/infra-controller-rest/db/pkg/db/paginator" +) + +// ~~~~~ Update Handler ~~~~~ // + +// UpdateTenantCapabilityHandler is the API Handler for enabling or disabling a scoped +// tenant capability (e.g. TargetedInstanceCreation) for a resolved set of Sites. +type UpdateTenantCapabilityHandler struct { + dbSession *cdb.Session + tc temporalClient.Client + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewUpdateTenantCapabilityHandler initializes and returns a new handler for updating tenant capabilities +func NewUpdateTenantCapabilityHandler(dbSession *cdb.Session, tc temporalClient.Client, cfg *config.Config) UpdateTenantCapabilityHandler { + return UpdateTenantCapabilityHandler{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Update scoped tenant capabilities +// @Description Enable or disable a scoped capability (e.g. TargetedInstanceCreation) for a resolved set of Sites +// @Tags tenant +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param tenantId path string true "ID of Tenant" +// @Param message body model.APITenantCapabilityUpdateRequest true "Tenant capability update request" +// @Success 200 {object} model.APITenantCapability +// @Router /v2/org/{org}/nico/tenant/{tenantId}/capabilities [patch] +func (utch UpdateTenantCapabilityHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("TenantCapability", "Update", c, utch.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + if dbUser == nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate org + ok, err := auth.ValidateOrgMembership(dbUser, org) + if !ok { + if err != nil { + logger.Error().Err(err).Msg("error validating org membership for User in request") + } else { + logger.Warn().Msg("could not validate org membership for user, access denied") + } + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) + } + + // Validate role, only Tenant Admins may configure scoped capabilities for their tenant + 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) + } + + // Get tenant ID from URL param + tenantStrID := c.Param("tenantId") + + utch.tracerSpan.SetAttribute(handlerSpan, attribute.String("tenant_id", tenantStrID), logger) + + tenantID, err := uuid.Parse(tenantStrID) + if err != nil { + logger.Warn().Err(err).Msg("error parsing tenantId in url into uuid") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Tenant ID in URL", nil) + } + + // Validate request + // Bind request data to API model + apiRequest := model.APITenantCapabilityUpdateRequest{} + err = c.Bind(&apiRequest) + if err != nil { + logger.Warn().Err(err).Msg("error binding request data into API model") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request data, potentially invalid structure", nil) + } + + // Validate request attributes + verr := apiRequest.Validate() + if verr != nil { + logger.Warn().Err(verr).Msg("error validating Tenant capability update request data") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating Tenant capability update request data", verr) + } + + enabled := *apiRequest.Enabled + + // Resolve the tenant for this org and ensure it matches the tenant in the URL. A Tenant + // Admin may only configure capabilities for their own org's tenant. + tenant, err := common.GetTenantForOrg(ctx, nil, utch.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 a tenant", nil) + } + if tenant.ID != tenantID { + logger.Warn().Msg("tenant in URL does not match tenant for org") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant in URL does not belong to org", nil) + } + + // Ceiling check: enabling a scoped capability requires the tenant-level entitlement. + // Disabling is always allowed (a site can be turned off while the ceiling stays true). + if enabled && !common.TenantHasTargetedInstanceCreation(tenant) { + logger.Warn().Msg("tenant does not have the targeted instance creation ceiling, cannot enable capability") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant must have the TargetedInstanceCreation capability enabled before scoping it to sites", nil) + } + + // Optional provider filter + var providerFilter *uuid.UUID + if apiRequest.InfrastructureProviderID != nil { + pid, perr := uuid.Parse(*apiRequest.InfrastructureProviderID) + if perr != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Infrastructure Provider ID in request", nil) + } + providerFilter = &pid + } + + // Resolve the eligible Site set for the tenant: the union of TenantSite memberships and + // Sites reachable via Ready TenantAccounts. This mirrors the Site listing behavior. + eligibleSites, err := utch.resolveEligibleSites(ctx, logger, tenant.ID) + if err != nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to resolve eligible Sites for Tenant", nil) + } + + // Determine the target Sites from the request. + targetSites, aerr := resolveTargetSites(apiRequest.Sites, providerFilter, eligibleSites) + if aerr != nil { + logger.Warn().Err(aerr).Msg("error resolving target sites for capability update") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, aerr.Error(), nil) + } + + tscaDAO := cdbm.NewTenantSiteCapabilityAssociationDAO(utch.dbSession) + + appliedSiteIDs := []string{} + + err = cdb.WithTx(ctx, utch.dbSession, func(tx *cdb.Tx) error { + // Serialize concurrent capability updates for the same tenant so the read-then-write + // per site sees a consistent snapshot. + derr := tx.TryAcquireAdvisoryLock(ctx, cdb.GetAdvisoryLockIDFromString(fmt.Sprintf("tsca-%s", tenant.ID.String())), nil) + if derr != nil { + logger.Error().Err(derr).Msg("error acquiring advisory lock for tenant capability update") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to acquire lock for capability update", nil) + } + + for _, site := range targetSites { + existing, gerr := tscaDAO.GetByTenantIDAndSiteID(ctx, tx, tenant.ID, site.ID, nil) + if gerr != nil && gerr != cdb.ErrDoesNotExist { + logger.Error().Err(gerr).Msg("error retrieving existing capability association") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve capability association", nil) + } + + if gerr == cdb.ErrDoesNotExist { + // No association yet. Disabling is a no-op (absent ⇒ effective false); + // only materialize a row when enabling. + if !enabled { + continue + } + _, cerr := tscaDAO.Create(ctx, tx, cdbm.TenantSiteCapabilityAssociationCreateInput{ + TenantID: tenant.ID, + SiteID: site.ID, + InfrastructureProviderID: site.InfrastructureProviderID, + TargetedInstanceCreation: enabled, + CreatedBy: dbUser.ID, + }) + if cerr != nil { + logger.Error().Err(cerr).Msg("error creating capability association") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to create capability association", nil) + } + appliedSiteIDs = append(appliedSiteIDs, site.ID.String()) + continue + } + + // Association exists; update the flag in place. + _, uerr := tscaDAO.Update(ctx, tx, cdbm.TenantSiteCapabilityAssociationUpdateInput{ + TenantSiteCapabilityAssociationID: existing.ID, + TargetedInstanceCreation: cdb.GetBoolPtr(enabled), + }) + if uerr != nil { + logger.Error().Err(uerr).Msg("error updating capability association") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update capability association", nil) + } + appliedSiteIDs = append(appliedSiteIDs, site.ID.String()) + } + + return nil + }) + if err != nil { + return common.HandleTxError(c, logger, err, "Failed to update Tenant capabilities, DB transaction error") + } + + apiResponse := model.APITenantCapability{ + CapabilityName: apiRequest.CapabilityName, + Enabled: enabled, + SiteIDs: appliedSiteIDs, + } + + logger.Info().Msg("finishing API handler") + + return c.JSON(http.StatusOK, apiResponse) +} + +// resolveEligibleSites returns the Sites a tenant may scope capabilities to: the union of its +// TenantSite memberships and the Sites of providers it has a Ready TenantAccount with. The +// returned map is keyed by Site ID so callers can look up each Site's provider. +func (utch UpdateTenantCapabilityHandler) resolveEligibleSites(ctx context.Context, logger zerolog.Logger, tenantID uuid.UUID) (map[uuid.UUID]*cdbm.Site, error) { + stDAO := cdbm.NewSiteDAO(utch.dbSession) + tsDAO := cdbm.NewTenantSiteDAO(utch.dbSession) + taDAO := cdbm.NewTenantAccountDAO(utch.dbSession) + + eligibleSiteIDs := mapset.NewSet[uuid.UUID]() + + tss, _, serr := tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenantID}}, cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving Tenant Site associations from DB by Tenant ID") + return nil, serr + } + for _, ts := range tss { + eligibleSiteIDs.Add(ts.SiteID) + } + + tas, _, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{ + TenantIDs: []uuid.UUID{tenantID}, + Statuses: []string{cdbm.TenantAccountStatusReady}, + }, cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving Tenant Accounts for Tenant") + return nil, serr + } + if len(tas) > 0 { + providerIDs := make([]uuid.UUID, 0, len(tas)) + for _, ta := range tas { + providerIDs = append(providerIDs, ta.InfrastructureProviderID) + } + providerSites, _, serr := stDAO.GetAll(ctx, nil, cdbm.SiteFilterInput{InfrastructureProviderIDs: providerIDs}, cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving Sites for Providers from Tenant Accounts") + return nil, serr + } + for _, site := range providerSites { + eligibleSiteIDs.Add(site.ID) + } + } + + eligible := map[uuid.UUID]*cdbm.Site{} + if eligibleSiteIDs.Cardinality() == 0 { + return eligible, nil + } + + // Fetch the resolved Sites in one pass to get each Site's provider id uniformly. + sites, _, serr := stDAO.GetAll(ctx, nil, cdbm.SiteFilterInput{SiteIDs: eligibleSiteIDs.ToSlice()}, cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving eligible Sites from DB") + return nil, serr + } + for i := range sites { + site := sites[i] + eligible[site.ID] = &site + } + + return eligible, nil +} + +// resolveTargetSites maps the request's site selection onto the eligible Site set, applying the +// optional provider filter. An empty requestedSites means "all eligible sites". +func resolveTargetSites(requestedSites []string, providerFilter *uuid.UUID, eligible map[uuid.UUID]*cdbm.Site) ([]*cdbm.Site, error) { + targets := []*cdbm.Site{} + + if len(requestedSites) == 0 { + for _, site := range eligible { + if providerFilter != nil && site.InfrastructureProviderID != *providerFilter { + continue + } + targets = append(targets, site) + } + return targets, nil + } + + for _, raw := range requestedSites { + siteID, perr := uuid.Parse(raw) + if perr != nil { + return nil, fmt.Errorf("invalid Site ID in request: %s", raw) + } + site, ok := eligible[siteID] + if !ok { + return nil, fmt.Errorf("Site %s is not eligible for this Tenant", raw) + } + if providerFilter != nil && site.InfrastructureProviderID != *providerFilter { + return nil, fmt.Errorf("Site %s does not belong to the specified Infrastructure Provider", raw) + } + targets = append(targets, site) + } + + return targets, nil +} diff --git a/api/pkg/api/handler/tenantcapability_test.go b/api/pkg/api/handler/tenantcapability_test.go new file mode 100644 index 000000000..f330138d9 --- /dev/null +++ b/api/pkg/api/handler/tenantcapability_test.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NVIDIA/infra-controller-rest/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller-rest/api/pkg/api/model" + authz "github.com/NVIDIA/infra-controller-rest/auth/pkg/authorization" + "github.com/NVIDIA/infra-controller-rest/common/pkg/otelecho" + cdb "github.com/NVIDIA/infra-controller-rest/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller-rest/db/pkg/db/model" + cdbu "github.com/NVIDIA/infra-controller-rest/db/pkg/util" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uptrace/bun/extra/bundebug" + oteltrace "go.opentelemetry.io/otel/trace" + tmocks "go.temporal.io/sdk/mocks" +) + +func testTenantCapabilityInitDB(t *testing.T) *cdb.Session { + dbSession := cdbu.GetTestDBSession(t, false) + dbSession.DB.AddQueryHook(bundebug.NewQueryHook( + bundebug.WithEnabled(false), + bundebug.FromEnv("BUNDEBUG"), + )) + return dbSession +} + +// testTenantCapabilitySetupSchema resets the tables needed for tenant capability tests. +// Referenced tables are created before the tables that hold foreign keys to them. +func testTenantCapabilitySetupSchema(t *testing.T, dbSession *cdb.Session) { + models := []interface{}{ + (*cdbm.User)(nil), + (*cdbm.InfrastructureProvider)(nil), + (*cdbm.Tenant)(nil), + (*cdbm.Site)(nil), + (*cdbm.TenantSite)(nil), + (*cdbm.TenantAccount)(nil), + (*cdbm.TenantSiteCapabilityAssociation)(nil), + } + for _, m := range models { + err := dbSession.DB.ResetModel(context.Background(), m) + require.NoError(t, err) + } +} + +func testTenantCapabilityBuildTenant(t *testing.T, dbSession *cdb.Session, org string, user *cdbm.User, targetedInstanceCreation bool) *cdbm.Tenant { + tnDAO := cdbm.NewTenantDAO(dbSession) + tn, err := tnDAO.CreateFromParams(context.Background(), nil, org, cdb.GetStrPtr("Test Tenant"), org, cdb.GetStrPtr(org), + &cdbm.TenantConfig{TargetedInstanceCreation: targetedInstanceCreation}, user) + require.NoError(t, err) + return tn +} + +func testTenantCapabilityBuildTenantAccount(t *testing.T, dbSession *cdb.Session, ip *cdbm.InfrastructureProvider, tn *cdbm.Tenant, status string, createdBy uuid.UUID) *cdbm.TenantAccount { + taDAO := cdbm.NewTenantAccountDAO(dbSession) + ta, err := taDAO.Create(context.Background(), nil, cdbm.TenantAccountCreateInput{ + AccountNumber: uuid.New().String(), + TenantID: &tn.ID, + TenantOrg: tn.Org, + InfrastructureProviderID: ip.ID, + InfrastructureProviderOrg: ip.Org, + Status: status, + CreatedBy: createdBy, + }) + require.NoError(t, err) + return ta +} + +func mustMarshal(t *testing.T, v interface{}) string { + b, err := json.Marshal(v) + require.NoError(t, err) + return string(b) +} + +func TestTenantCapabilityHandler_Update(t *testing.T) { + ctx := context.Background() + dbSession := testTenantCapabilityInitDB(t) + defer dbSession.Close() + + testTenantCapabilitySetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + tnOrg := "test-tn-org" + tnOrgNoCeiling := "test-tn-org-no-ceiling" + tnOrgNoTenant := "test-tn-org-no-tenant" + + ipUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, []string{authz.ProviderAdminRole}) + tnUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, []string{authz.TenantAdminRole}) + tnUserNoCeiling := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrgNoCeiling, []string{authz.TenantAdminRole}) + // A user who is a member of the tenant org but lacks the Tenant Admin role. + tnNonAdminUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, []string{authz.ProviderAdminRole}) + // A user who is a member of an org that has no tenant entity. + noTenantUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrgNoTenant, []string{authz.TenantAdminRole}) + + ip := cdbm.TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipUser) + ip2 := cdbm.TestBuildInfrastructureProvider(t, dbSession, "Test Provider 2", ipOrg+"-2", ipUser) + + // Privileged tenant (ceiling enabled) and a non-privileged tenant (ceiling disabled). + tn := testTenantCapabilityBuildTenant(t, dbSession, tnOrg, tnUser, true) + tnNoCeiling := testTenantCapabilityBuildTenant(t, dbSession, tnOrgNoCeiling, tnUserNoCeiling, false) + + // Sites: site1 + site2 under ip (eligible via TenantSite + Ready TenantAccount), + // site3 under ip2 (not eligible for tn). + site1 := cdbm.TestBuildSite(t, dbSession, ip, "Test Site 1", ipUser) + // site2 is reachable via the Ready TenantAccount with ip; only needs to exist in the DB. + cdbm.TestBuildSite(t, dbSession, ip, "Test Site 2", ipUser) + site3 := cdbm.TestBuildSite(t, dbSession, ip2, "Test Site 3", ipUser) + + // tn is a member of site1 and has a Ready account with ip (reaching site1 + site2). + cdbm.TestBuildTenantSite(t, dbSession, tn, site1, map[string]interface{}{}, tnUser) + testTenantCapabilityBuildTenantAccount(t, dbSession, ip, tn, cdbm.TenantAccountStatusReady, ipUser.ID) + + enableAllBody := mustMarshal(t, model.APITenantCapabilityUpdateRequest{ + CapabilityName: model.CapabilityNameTargetedInstanceCreation, + Sites: []string{}, + Enabled: cdb.GetBoolPtr(true), + }) + enableSite1Body := mustMarshal(t, model.APITenantCapabilityUpdateRequest{ + CapabilityName: model.CapabilityNameTargetedInstanceCreation, + Sites: []string{site1.ID.String()}, + Enabled: cdb.GetBoolPtr(true), + }) + disableSite1Body := mustMarshal(t, model.APITenantCapabilityUpdateRequest{ + CapabilityName: model.CapabilityNameTargetedInstanceCreation, + Sites: []string{site1.ID.String()}, + Enabled: cdb.GetBoolPtr(false), + }) + enableIneligibleSiteBody := mustMarshal(t, model.APITenantCapabilityUpdateRequest{ + CapabilityName: model.CapabilityNameTargetedInstanceCreation, + Sites: []string{site3.ID.String()}, + Enabled: cdb.GetBoolPtr(true), + }) + providerMismatchBody := mustMarshal(t, model.APITenantCapabilityUpdateRequest{ + CapabilityName: model.CapabilityNameTargetedInstanceCreation, + Sites: []string{site1.ID.String()}, + InfrastructureProviderID: cdb.GetStrPtr(ip2.ID.String()), + Enabled: cdb.GetBoolPtr(true), + }) + unknownCapabilityBody := mustMarshal(t, model.APITenantCapabilityUpdateRequest{ + CapabilityName: "NotARealCapability", + Enabled: cdb.GetBoolPtr(true), + }) + missingEnabledBody := mustMarshal(t, model.APITenantCapabilityUpdateRequest{ + CapabilityName: model.CapabilityNameTargetedInstanceCreation, + }) + + cfg := common.GetTestConfig() + tempClient := &tmocks.Client{} + + // OTEL Spanner configuration + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + reqOrgName string + tenantID string + reqBody string + user *cdbm.User + expectedStatus int + expectedEnabled bool + expectedSiteCount int + verifyChildSpanner bool + }{ + { + name: "error when user not found in request context", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: enableAllBody, + user: nil, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "error when user is not a tenant admin", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: enableAllBody, + user: tnNonAdminUser, + expectedStatus: http.StatusForbidden, + }, + { + name: "error when tenantId in URL is not a valid uuid", + reqOrgName: tnOrg, + tenantID: "not-a-uuid", + reqBody: enableAllBody, + user: tnUser, + expectedStatus: http.StatusBadRequest, + }, + { + name: "error when request body does not bind", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: "not-json", + user: tnUser, + expectedStatus: http.StatusBadRequest, + }, + { + name: "error when enabled is missing", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: missingEnabledBody, + user: tnUser, + expectedStatus: http.StatusBadRequest, + }, + { + name: "error when capabilityName is unknown", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: unknownCapabilityBody, + user: tnUser, + expectedStatus: http.StatusBadRequest, + }, + { + name: "error when org has no tenant", + reqOrgName: tnOrgNoTenant, + tenantID: tn.ID.String(), + reqBody: enableAllBody, + user: noTenantUser, + expectedStatus: http.StatusNotFound, + }, + { + name: "error when tenant in URL does not match org tenant", + reqOrgName: tnOrg, + tenantID: uuid.New().String(), + reqBody: enableAllBody, + user: tnUser, + expectedStatus: http.StatusForbidden, + }, + { + name: "error when ceiling is not set and enabling", + reqOrgName: tnOrgNoCeiling, + tenantID: tnNoCeiling.ID.String(), + reqBody: enableAllBody, + user: tnUserNoCeiling, + expectedStatus: http.StatusForbidden, + }, + { + name: "error when listed site is not eligible", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: enableIneligibleSiteBody, + user: tnUser, + expectedStatus: http.StatusBadRequest, + }, + { + name: "error when listed site does not match provider filter", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: providerMismatchBody, + user: tnUser, + expectedStatus: http.StatusBadRequest, + }, + { + name: "success enabling specific site", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: enableSite1Body, + user: tnUser, + expectedStatus: http.StatusOK, + expectedEnabled: true, + expectedSiteCount: 1, + }, + { + name: "success disabling specific site", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: disableSite1Body, + user: tnUser, + expectedStatus: http.StatusOK, + expectedEnabled: false, + expectedSiteCount: 1, + }, + { + name: "success enabling all eligible sites", + reqOrgName: tnOrg, + tenantID: tn.ID.String(), + reqBody: enableAllBody, + user: tnUser, + expectedStatus: http.StatusOK, + expectedEnabled: true, + expectedSiteCount: 2, + verifyChildSpanner: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup echo server/context + e := echo.New() + req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(tc.reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + ec := e.NewContext(req, rec) + ec.SetParamNames("orgName", "tenantId") + ec.SetParamValues(tc.reqOrgName, tc.tenantID) + if tc.user != nil { + ec.Set("user", tc.user) + } + + ctx = context.WithValue(ctx, otelecho.TracerKey, tracer) + ec.SetRequest(ec.Request().WithContext(ctx)) + + h := UpdateTenantCapabilityHandler{ + dbSession: dbSession, + tc: tempClient, + cfg: cfg, + } + err := h.Handle(ec) + assert.Nil(t, err) + + if tc.expectedStatus != rec.Code { + t.Errorf("response: %v\n", rec.Body.String()) + } + require.Equal(t, tc.expectedStatus, rec.Code) + + if tc.expectedStatus == http.StatusOK { + rsp := &model.APITenantCapability{} + uerr := json.Unmarshal(rec.Body.Bytes(), rsp) + assert.Nil(t, uerr) + assert.Equal(t, model.CapabilityNameTargetedInstanceCreation, rsp.CapabilityName) + assert.Equal(t, tc.expectedEnabled, rsp.Enabled) + assert.Equal(t, tc.expectedSiteCount, len(rsp.SiteIDs)) + } + + if tc.verifyChildSpanner { + span := oteltrace.SpanFromContext(ec.Request().Context()) + assert.True(t, span.SpanContext().IsValid()) + } + }) + } +} diff --git a/api/pkg/api/model/tenantcapability.go b/api/pkg/api/model/tenantcapability.go new file mode 100644 index 000000000..bef8ae544 --- /dev/null +++ b/api/pkg/api/model/tenantcapability.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + validationis "github.com/go-ozzo/ozzo-validation/v4/is" +) + +const ( + // CapabilityNameTargetedInstanceCreation is the capability name for targeted instance creation. + // It is the first (and currently only) scoped tenant capability. + CapabilityNameTargetedInstanceCreation = "TargetedInstanceCreation" + + validationErrorUnknownCapabilityName = "unknown capability name" + validationErrorEnabledRequired = "enabled must be specified" +) + +// SupportedCapabilityNames is the set of capability names the capabilities endpoint accepts. +var SupportedCapabilityNames = []interface{}{ + CapabilityNameTargetedInstanceCreation, +} + +// APITenantCapabilityUpdateRequest is the data structure to capture a tenant admin's request +// to enable or disable a scoped capability for a resolved set of sites. +// +// An empty Sites array means "all eligible sites" (optionally restricted by +// InfrastructureProviderID); a non-empty array scopes the change to the listed sites. +type APITenantCapabilityUpdateRequest struct { + // CapabilityName is the capability being toggled, e.g. "TargetedInstanceCreation". + CapabilityName string `json:"capabilityName"` + // Sites is the list of Site IDs to scope the change to. Empty ⇒ all eligible sites. + Sites []string `json:"sites"` + // InfrastructureProviderID optionally restricts the resolved site set to sites owned + // by that provider. When Sites is non-empty, it is used for validation only. + InfrastructureProviderID *string `json:"infrastructureProviderId"` + // Enabled selects the operation: true opts in, false opts out, for the resolved sites. + Enabled *bool `json:"enabled"` +} + +// Validate ensures the values passed in request are acceptable +func (tcur APITenantCapabilityUpdateRequest) Validate() error { + return validation.ValidateStruct(&tcur, + validation.Field(&tcur.CapabilityName, + validation.Required.Error(validationErrorValueRequired), + validation.In(SupportedCapabilityNames...).Error(validationErrorUnknownCapabilityName)), + validation.Field(&tcur.Sites, + validation.Each(validationis.UUID.Error(validationErrorInvalidUUID))), + validation.Field(&tcur.InfrastructureProviderID, + validationis.UUID.Error(validationErrorInvalidUUID)), + validation.Field(&tcur.Enabled, + validation.NotNil.Error(validationErrorEnabledRequired)), + ) +} + +// APITenantCapability is the API representation of the result of a capabilities update: +// the capability, whether it was enabled or disabled, and the resolved sites it was applied to. +type APITenantCapability struct { + // CapabilityName is the capability that was toggled. + CapabilityName string `json:"capabilityName"` + // Enabled reflects the operation applied to the resolved sites. + Enabled bool `json:"enabled"` + // SiteIDs are the Site IDs the change was applied to. + SiteIDs []string `json:"siteIds"` +} diff --git a/api/pkg/api/routes.go b/api/pkg/api/routes.go index 6acaf8b4f..ee269975f 100644 --- a/api/pkg/api/routes.go +++ b/api/pkg/api/routes.go @@ -83,6 +83,12 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa Method: http.MethodGet, Handler: apiHandler.NewGetCurrentTenantStatsHandler(dbSession, tc, cfg), }, + // Tenant Capability endpoint + { + Path: apiPathPrefix + "/tenant/:tenantId/capabilities", + Method: http.MethodPatch, + Handler: apiHandler.NewUpdateTenantCapabilityHandler(dbSession, tc, cfg), + }, // Tenant Instance Type Stats endpoint { Path: apiPathPrefix + "/tenant/instance-type/stats", diff --git a/api/pkg/api/routes_test.go b/api/pkg/api/routes_test.go index 5134dc56c..5729563f5 100644 --- a/api/pkg/api/routes_test.go +++ b/api/pkg/api/routes_test.go @@ -37,7 +37,7 @@ func TestNewAPIRoutes(t *testing.T) { "metadata": 1, "service-account": 1, "infrastructure-provider": 4, - "tenant": 4, + "tenant": 5, "tenant-account": 5, "site": 6, "vpc": 6, diff --git a/db/pkg/db/model/tenantsitecapabilityassociation.go b/db/pkg/db/model/tenantsitecapabilityassociation.go index 733e09a95..9c35a401e 100644 --- a/db/pkg/db/model/tenantsitecapabilityassociation.go +++ b/db/pkg/db/model/tenantsitecapabilityassociation.go @@ -1,19 +1,5 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package model diff --git a/db/pkg/db/model/tenantsitecapabilityassociation_test.go b/db/pkg/db/model/tenantsitecapabilityassociation_test.go index 45a5debcf..a0d9ab579 100644 --- a/db/pkg/db/model/tenantsitecapabilityassociation_test.go +++ b/db/pkg/db/model/tenantsitecapabilityassociation_test.go @@ -1,19 +1,5 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package model diff --git a/db/pkg/migrations/20260529160000_tenant_site_capability_association.go b/db/pkg/migrations/20260529160000_tenant_site_capability_association.go index 1dde8873a..d5d299e41 100644 --- a/db/pkg/migrations/20260529160000_tenant_site_capability_association.go +++ b/db/pkg/migrations/20260529160000_tenant_site_capability_association.go @@ -1,19 +1,5 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package migrations diff --git a/flow/internal/task/message/message.go b/flow/internal/task/message/message.go index 4fdbb9855..d2fe2de9c 100644 --- a/flow/internal/task/message/message.go +++ b/flow/internal/task/message/message.go @@ -1,19 +1,5 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package message diff --git a/flow/internal/task/operations/summary.go b/flow/internal/task/operations/summary.go index eb9e833d1..97dce77ac 100644 --- a/flow/internal/task/operations/summary.go +++ b/flow/internal/task/operations/summary.go @@ -1,19 +1,5 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package operations diff --git a/flow/internal/task/operations/summary_test.go b/flow/internal/task/operations/summary_test.go index a55427b5c..b4fceff21 100644 --- a/flow/internal/task/operations/summary_test.go +++ b/flow/internal/task/operations/summary_test.go @@ -1,19 +1,5 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package operations diff --git a/flow/internal/task/report/report.go b/flow/internal/task/report/report.go index baff5b991..38d784abd 100644 --- a/flow/internal/task/report/report.go +++ b/flow/internal/task/report/report.go @@ -1,19 +1,5 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 // Package report defines the JSON document persisted in task.report and // the Tracker that mutates it as a rule-based workflow advances. diff --git a/openapi/spec.yaml b/openapi/spec.yaml index 2ba582a26..e5628a397 100644 --- a/openapi/spec.yaml +++ b/openapi/spec.yaml @@ -466,6 +466,77 @@ paths: User must have authorization role with `TENANT_ADMIN` suffix. parameters: [] + '/v2/org/{org}/nico/tenant/{tenantId}/capabilities': + parameters: + - schema: + type: string + name: org + in: path + required: true + description: Name of the Org + - schema: + type: string + format: uuid + name: tenantId + in: path + required: true + description: ID of the Tenant + patch: + summary: Update scoped Tenant capabilities + operationId: update-tenant-capabilities + tags: + - Tenant + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TenantCapability' + examples: + example-1: + value: + capabilityName: TargetedInstanceCreation + enabled: true + siteIds: + - 550e8400-e29b-41d4-a716-446655440000 + - 6ba7b810-9dad-11d1-80b4-00c04fd430c8 + '400': + $ref: '#/components/responses/ValidationError' + '403': + $ref: '#/components/responses/ForbiddenError' + description: |- + Enable or disable a scoped capability (e.g. `TargetedInstanceCreation`) for a resolved set of Sites. + + Org must have a Tenant entity whose ID matches the `tenantId` path param. User must have authorization role with `TENANT_ADMIN` suffix. + + Enabling a capability requires the tenant-level entitlement (`Tenant.Config`) to already be set; disabling is always permitted. + + An empty `sites` array applies the change to all eligible Sites (the union of Tenant Site memberships and Sites reachable via `Ready` Tenant Accounts), optionally restricted by `infrastructureProviderId`. A non-empty `sites` array scopes the change to the listed Sites. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TenantCapabilityUpdateRequest' + examples: + enable-all-sites: + value: + capabilityName: TargetedInstanceCreation + sites: [] + enabled: true + enable-specific-sites: + value: + capabilityName: TargetedInstanceCreation + sites: + - 550e8400-e29b-41d4-a716-446655440000 + enabled: true + disable-specific-sites: + value: + capabilityName: TargetedInstanceCreation + sites: + - 550e8400-e29b-41d4-a716-446655440000 + enabled: false + description: Tenant capability update request '/v2/org/{org}/nico/tenant/account': parameters: - schema: @@ -13119,6 +13190,59 @@ components: No params needed, an empty request will suffice. examples: - {} + TenantCapabilityUpdateRequest: + title: TenantCapabilityUpdateRequest + type: object + description: |- + Request data to enable or disable a scoped Tenant capability for a resolved set of Sites. + + An empty `sites` array applies the change to all eligible Sites (optionally filtered by + `infrastructureProviderId`); a non-empty array scopes the change to the listed Sites. + examples: + - capabilityName: TargetedInstanceCreation + sites: [] + enabled: true + properties: + capabilityName: + type: string + description: The capability to toggle + enum: + - TargetedInstanceCreation + sites: + type: array + description: Site IDs to scope the change to. Empty array means all eligible Sites. + items: + type: string + format: uuid + infrastructureProviderId: + type: string + format: uuid + description: Optionally restrict the resolved Site set to Sites owned by this provider + enabled: + type: boolean + description: true opts in, false opts out, for the resolved Sites + required: + - capabilityName + - enabled + TenantCapability: + title: TenantCapability + type: object + description: Result of a scoped Tenant capability update + examples: + - capabilityName: TargetedInstanceCreation + enabled: true + siteIds: + - 550e8400-e29b-41d4-a716-446655440000 + properties: + capabilityName: + type: string + enabled: + type: boolean + siteIds: + type: array + items: + type: string + format: uuid Site: title: Site type: object diff --git a/sdk/standard/api_allocation.go b/sdk/standard/api_allocation.go index 2743cfce7..1e0c973c5 100644 --- a/sdk/standard/api_allocation.go +++ b/sdk/standard/api_allocation.go @@ -463,6 +463,7 @@ func (a *AllocationAPIService) GetAllAllocationExecute(r ApiGetAllAllocationRequ parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_audit.go b/sdk/standard/api_audit.go index 7d744f6cc..40755a1c6 100644 --- a/sdk/standard/api_audit.go +++ b/sdk/standard/api_audit.go @@ -112,6 +112,7 @@ func (a *AuditAPIService) GetAllAuditEntryExecute(r ApiGetAllAuditEntryRequest) parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_dpu_extension_service.go b/sdk/standard/api_dpu_extension_service.go index 0c767752b..2f5bae0ea 100644 --- a/sdk/standard/api_dpu_extension_service.go +++ b/sdk/standard/api_dpu_extension_service.go @@ -501,6 +501,7 @@ func (a *DPUExtensionServiceAPIService) GetAllDpuExtensionServiceExecute(r ApiGe parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_expected_machine.go b/sdk/standard/api_expected_machine.go index adbf69912..31ab90610 100644 --- a/sdk/standard/api_expected_machine.go +++ b/sdk/standard/api_expected_machine.go @@ -710,6 +710,7 @@ func (a *ExpectedMachineAPIService) GetAllExpectedMachineExecute(r ApiGetAllExpe parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_expected_power_shelf.go b/sdk/standard/api_expected_power_shelf.go index bf326b795..14357540a 100644 --- a/sdk/standard/api_expected_power_shelf.go +++ b/sdk/standard/api_expected_power_shelf.go @@ -399,6 +399,7 @@ func (a *ExpectedPowerShelfAPIService) GetAllExpectedPowerShelfExecute(r ApiGetA parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_expected_rack.go b/sdk/standard/api_expected_rack.go index a630c998a..dfeadfd76 100644 --- a/sdk/standard/api_expected_rack.go +++ b/sdk/standard/api_expected_rack.go @@ -533,6 +533,7 @@ func (a *ExpectedRackAPIService) GetAllExpectedRackExecute(r ApiGetAllExpectedRa parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_expected_switch.go b/sdk/standard/api_expected_switch.go index f11c9bb7b..3f3831083 100644 --- a/sdk/standard/api_expected_switch.go +++ b/sdk/standard/api_expected_switch.go @@ -399,6 +399,7 @@ func (a *ExpectedSwitchAPIService) GetAllExpectedSwitchExecute(r ApiGetAllExpect parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_infini_band_partition.go b/sdk/standard/api_infini_band_partition.go index 13f1f7cea..586118f0d 100644 --- a/sdk/standard/api_infini_band_partition.go +++ b/sdk/standard/api_infini_band_partition.go @@ -397,6 +397,7 @@ func (a *InfiniBandPartitionAPIService) GetAllInfinibandInterfaceExecute(r ApiGe parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -586,6 +587,7 @@ func (a *InfiniBandPartitionAPIService) GetAllInfinibandPartitionExecute(r ApiGe parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_instance.go b/sdk/standard/api_instance.go index 938b28f71..141f96905 100644 --- a/sdk/standard/api_instance.go +++ b/sdk/standard/api_instance.go @@ -614,6 +614,7 @@ func (a *InstanceAPIService) GetAllInstanceExecute(r ApiGetAllInstanceRequest) ( parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -790,6 +791,7 @@ func (a *InstanceAPIService) GetAllInstanceInfinibandInterfaceExecute(r ApiGetAl parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -963,6 +965,7 @@ func (a *InstanceAPIService) GetAllInstanceNvlinkInterfaceExecute(r ApiGetAllIns parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -1136,6 +1139,7 @@ func (a *InstanceAPIService) GetAllInterfaceExecute(r ApiGetAllInterfaceRequest) parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_instance_type.go b/sdk/standard/api_instance_type.go index 8ecad1176..dadaa7a60 100644 --- a/sdk/standard/api_instance_type.go +++ b/sdk/standard/api_instance_type.go @@ -694,6 +694,7 @@ func (a *InstanceTypeAPIService) GetAllInstanceTypeExecute(r ApiGetAllInstanceTy parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -999,6 +1000,7 @@ func (a *InstanceTypeAPIService) GetInstanceTypeMachineAssociationExecute(r ApiG parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_ip_block.go b/sdk/standard/api_ip_block.go index f0d1e36b7..99e9de55d 100644 --- a/sdk/standard/api_ip_block.go +++ b/sdk/standard/api_ip_block.go @@ -396,6 +396,7 @@ func (a *IPBlockAPIService) GetAllDerivedIpblockExecute(r ApiGetAllDerivedIpbloc parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -615,6 +616,7 @@ func (a *IPBlockAPIService) GetAllIpblockExecute(r ApiGetAllIpblockRequest) ([]I parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_machine.go b/sdk/standard/api_machine.go index 85e52a8ae..1bac204d6 100644 --- a/sdk/standard/api_machine.go +++ b/sdk/standard/api_machine.go @@ -392,6 +392,7 @@ func (a *MachineAPIService) GetAllMachineExecute(r ApiGetAllMachineRequest) ([]M parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -632,6 +633,7 @@ func (a *MachineAPIService) GetAllMachineCapabilitiesExecute(r ApiGetAllMachineC parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_network_security_group.go b/sdk/standard/api_network_security_group.go index 70b2bee39..8b0f5edae 100644 --- a/sdk/standard/api_network_security_group.go +++ b/sdk/standard/api_network_security_group.go @@ -493,6 +493,7 @@ func (a *NetworkSecurityGroupAPIService) GetAllNetworkSecurityGroupExecute(r Api parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_nv_link_logical_partition.go b/sdk/standard/api_nv_link_logical_partition.go index 1db974c67..8406fb925 100644 --- a/sdk/standard/api_nv_link_logical_partition.go +++ b/sdk/standard/api_nv_link_logical_partition.go @@ -405,6 +405,7 @@ func (a *NVLinkLogicalPartitionAPIService) GetAllNvlinkInterfaceExecute(r ApiGet parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -624,6 +625,7 @@ func (a *NVLinkLogicalPartitionAPIService) GetAllNvlinkLogicalPartitionExecute(r parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_operating_system.go b/sdk/standard/api_operating_system.go index a368ed612..2b08e5cd9 100644 --- a/sdk/standard/api_operating_system.go +++ b/sdk/standard/api_operating_system.go @@ -405,6 +405,7 @@ func (a *OperatingSystemAPIService) GetAllOperatingSystemExecute(r ApiGetAllOper parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_rack.go b/sdk/standard/api_rack.go index 23f67992e..20ae88afd 100644 --- a/sdk/standard/api_rack.go +++ b/sdk/standard/api_rack.go @@ -703,6 +703,7 @@ func (a *RackAPIService) GetAllRackExecute(r ApiGetAllRackRequest) ([]Rack, *htt parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -1021,12 +1022,14 @@ func (a *RackAPIService) GetRackTasksExecute(r ApiGetRackTasksRequest) ([]RackTa parameterAddToHeaderOrQuery(localVarQueryParams, "activeOnly", r.activeOnly, "form", "") } else { var defaultValue bool = false + parameterAddToHeaderOrQuery(localVarQueryParams, "activeOnly", defaultValue, "form", "") r.activeOnly = &defaultValue } if r.pageNumber != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_site.go b/sdk/standard/api_site.go index af5d9abe2..55299b7ca 100644 --- a/sdk/standard/api_site.go +++ b/sdk/standard/api_site.go @@ -459,6 +459,7 @@ func (a *SiteAPIService) GetAllSiteExecute(r ApiGetAllSiteRequest) ([]Site, *htt parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_sku.go b/sdk/standard/api_sku.go index 32da6e78a..20d777329 100644 --- a/sdk/standard/api_sku.go +++ b/sdk/standard/api_sku.go @@ -119,6 +119,7 @@ func (a *SKUAPIService) GetAllSkuExecute(r ApiGetAllSkuRequest) ([]Sku, *http.Re parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_ssh_key.go b/sdk/standard/api_ssh_key.go index e246f53c7..6e5f59546 100644 --- a/sdk/standard/api_ssh_key.go +++ b/sdk/standard/api_ssh_key.go @@ -377,6 +377,7 @@ func (a *SSHKeyAPIService) GetAllSshKeyExecute(r ApiGetAllSshKeyRequest) ([]SshK parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_ssh_key_group.go b/sdk/standard/api_ssh_key_group.go index 8a8259b4d..ec5a0cf51 100644 --- a/sdk/standard/api_ssh_key_group.go +++ b/sdk/standard/api_ssh_key_group.go @@ -397,6 +397,7 @@ func (a *SSHKeyGroupAPIService) GetAllSshKeyGroupExecute(r ApiGetAllSshKeyGroupR parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_subnet.go b/sdk/standard/api_subnet.go index b7951d392..94fff406a 100644 --- a/sdk/standard/api_subnet.go +++ b/sdk/standard/api_subnet.go @@ -407,6 +407,7 @@ func (a *SubnetAPIService) GetAllSubnetExecute(r ApiGetAllSubnetRequest) ([]Subn parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_tenant.go b/sdk/standard/api_tenant.go index 2f35a6797..bf15322bf 100644 --- a/sdk/standard/api_tenant.go +++ b/sdk/standard/api_tenant.go @@ -383,3 +383,147 @@ func (a *TenantAPIService) GetTenantInstanceTypeStatsExecute(r ApiGetTenantInsta return localVarReturnValue, localVarHTTPResponse, nil } + +type ApiUpdateTenantCapabilitiesRequest struct { + ctx context.Context + ApiService *TenantAPIService + org string + tenantId string + tenantCapabilityUpdateRequest *TenantCapabilityUpdateRequest +} + +// Tenant capability update request +func (r ApiUpdateTenantCapabilitiesRequest) TenantCapabilityUpdateRequest(tenantCapabilityUpdateRequest TenantCapabilityUpdateRequest) ApiUpdateTenantCapabilitiesRequest { + r.tenantCapabilityUpdateRequest = &tenantCapabilityUpdateRequest + return r +} + +func (r ApiUpdateTenantCapabilitiesRequest) Execute() (*TenantCapability, *http.Response, error) { + return r.ApiService.UpdateTenantCapabilitiesExecute(r) +} + +/* +UpdateTenantCapabilities Update scoped Tenant capabilities + +Enable or disable a scoped capability (e.g. `TargetedInstanceCreation`) for a resolved set of Sites. + +Org must have a Tenant entity whose ID matches the `tenantId` path param. User must have authorization role with `TENANT_ADMIN` suffix. + +Enabling a capability requires the tenant-level entitlement (`Tenant.Config`) to already be set; disabling is always permitted. + +An empty `sites` array applies the change to all eligible Sites (the union of Tenant Site memberships and Sites reachable via `Ready` Tenant Accounts), optionally restricted by `infrastructureProviderId`. A non-empty `sites` array scopes the change to the listed Sites. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Org + @param tenantId ID of the Tenant + @return ApiUpdateTenantCapabilitiesRequest +*/ +func (a *TenantAPIService) UpdateTenantCapabilities(ctx context.Context, org string, tenantId string) ApiUpdateTenantCapabilitiesRequest { + return ApiUpdateTenantCapabilitiesRequest{ + ApiService: a, + ctx: ctx, + org: org, + tenantId: tenantId, + } +} + +// Execute executes the request +// +// @return TenantCapability +func (a *TenantAPIService) UpdateTenantCapabilitiesExecute(r ApiUpdateTenantCapabilitiesRequest) (*TenantCapability, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *TenantCapability + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "TenantAPIService.UpdateTenantCapabilities") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/tenant/{tenantId}/capabilities" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"tenantId"+"}", url.PathEscape(parameterValueToString(r.tenantId, "tenantId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.tenantCapabilityUpdateRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/sdk/standard/api_tenant_account.go b/sdk/standard/api_tenant_account.go index 6493b80fe..56c39e8e2 100644 --- a/sdk/standard/api_tenant_account.go +++ b/sdk/standard/api_tenant_account.go @@ -393,6 +393,7 @@ func (a *TenantAccountAPIService) GetAllTenantAccountExecute(r ApiGetAllTenantAc parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_tray.go b/sdk/standard/api_tray.go index d61cec974..e00bcf4d4 100644 --- a/sdk/standard/api_tray.go +++ b/sdk/standard/api_tray.go @@ -464,6 +464,7 @@ func (a *TrayAPIService) GetAllTrayExecute(r ApiGetAllTrayRequest) ([]Tray, *htt parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { @@ -772,12 +773,14 @@ func (a *TrayAPIService) GetTrayTasksExecute(r ApiGetTrayTasksRequest) ([]RackTa parameterAddToHeaderOrQuery(localVarQueryParams, "activeOnly", r.activeOnly, "form", "") } else { var defaultValue bool = false + parameterAddToHeaderOrQuery(localVarQueryParams, "activeOnly", defaultValue, "form", "") r.activeOnly = &defaultValue } if r.pageNumber != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_vpc.go b/sdk/standard/api_vpc.go index 898fbb549..d07322511 100644 --- a/sdk/standard/api_vpc.go +++ b/sdk/standard/api_vpc.go @@ -405,6 +405,7 @@ func (a *VPCAPIService) GetAllVpcExecute(r ApiGetAllVpcRequest) ([]VPC, *http.Re parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_vpc_peering.go b/sdk/standard/api_vpc_peering.go index 0187abc32..a1c1139d8 100644 --- a/sdk/standard/api_vpc_peering.go +++ b/sdk/standard/api_vpc_peering.go @@ -412,6 +412,7 @@ func (a *VPCPeeringAPIService) GetAllVpcPeeringExecute(r ApiGetAllVpcPeeringRequ parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/api_vpc_prefix.go b/sdk/standard/api_vpc_prefix.go index 2e7ee19bc..b6cd80156 100644 --- a/sdk/standard/api_vpc_prefix.go +++ b/sdk/standard/api_vpc_prefix.go @@ -405,6 +405,7 @@ func (a *VPCPrefixAPIService) GetAllVpcPrefixExecute(r ApiGetAllVpcPrefixRequest parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") } else { var defaultValue int32 = 1 + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", defaultValue, "form", "") r.pageNumber = &defaultValue } if r.pageSize != nil { diff --git a/sdk/standard/model_tenant_capability.go b/sdk/standard/model_tenant_capability.go new file mode 100644 index 000000000..377589830 --- /dev/null +++ b/sdk/standard/model_tenant_capability.go @@ -0,0 +1,196 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources e.g. VPC, Subnets, Instances across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the TenantCapability type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &TenantCapability{} + +// TenantCapability Result of a scoped Tenant capability update +type TenantCapability struct { + CapabilityName *string `json:"capabilityName,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + SiteIds []string `json:"siteIds,omitempty"` +} + +// NewTenantCapability instantiates a new TenantCapability 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 NewTenantCapability() *TenantCapability { + this := TenantCapability{} + return &this +} + +// NewTenantCapabilityWithDefaults instantiates a new TenantCapability 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 NewTenantCapabilityWithDefaults() *TenantCapability { + this := TenantCapability{} + return &this +} + +// GetCapabilityName returns the CapabilityName field value if set, zero value otherwise. +func (o *TenantCapability) GetCapabilityName() string { + if o == nil || IsNil(o.CapabilityName) { + var ret string + return ret + } + return *o.CapabilityName +} + +// GetCapabilityNameOk returns a tuple with the CapabilityName field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantCapability) GetCapabilityNameOk() (*string, bool) { + if o == nil || IsNil(o.CapabilityName) { + return nil, false + } + return o.CapabilityName, true +} + +// HasCapabilityName returns a boolean if a field has been set. +func (o *TenantCapability) HasCapabilityName() bool { + if o != nil && !IsNil(o.CapabilityName) { + return true + } + + return false +} + +// SetCapabilityName gets a reference to the given string and assigns it to the CapabilityName field. +func (o *TenantCapability) SetCapabilityName(v string) { + o.CapabilityName = &v +} + +// GetEnabled returns the Enabled field value if set, zero value otherwise. +func (o *TenantCapability) GetEnabled() bool { + if o == nil || IsNil(o.Enabled) { + var ret bool + return ret + } + return *o.Enabled +} + +// GetEnabledOk returns a tuple with the Enabled field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantCapability) GetEnabledOk() (*bool, bool) { + if o == nil || IsNil(o.Enabled) { + return nil, false + } + return o.Enabled, true +} + +// HasEnabled returns a boolean if a field has been set. +func (o *TenantCapability) HasEnabled() bool { + if o != nil && !IsNil(o.Enabled) { + return true + } + + return false +} + +// SetEnabled gets a reference to the given bool and assigns it to the Enabled field. +func (o *TenantCapability) SetEnabled(v bool) { + o.Enabled = &v +} + +// GetSiteIds returns the SiteIds field value if set, zero value otherwise. +func (o *TenantCapability) 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 *TenantCapability) 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 *TenantCapability) 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 *TenantCapability) SetSiteIds(v []string) { + o.SiteIds = v +} + +func (o TenantCapability) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o TenantCapability) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.CapabilityName) { + toSerialize["capabilityName"] = o.CapabilityName + } + if !IsNil(o.Enabled) { + toSerialize["enabled"] = o.Enabled + } + if !IsNil(o.SiteIds) { + toSerialize["siteIds"] = o.SiteIds + } + return toSerialize, nil +} + +type NullableTenantCapability struct { + value *TenantCapability + isSet bool +} + +func (v NullableTenantCapability) Get() *TenantCapability { + return v.value +} + +func (v *NullableTenantCapability) Set(val *TenantCapability) { + v.value = val + v.isSet = true +} + +func (v NullableTenantCapability) IsSet() bool { + return v.isSet +} + +func (v *NullableTenantCapability) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableTenantCapability(val *TenantCapability) *NullableTenantCapability { + return &NullableTenantCapability{value: val, isSet: true} +} + +func (v NullableTenantCapability) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableTenantCapability) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/sdk/standard/model_tenant_capability_update_request.go b/sdk/standard/model_tenant_capability_update_request.go new file mode 100644 index 000000000..a48f730c9 --- /dev/null +++ b/sdk/standard/model_tenant_capability_update_request.go @@ -0,0 +1,260 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources e.g. VPC, Subnets, Instances across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the TenantCapabilityUpdateRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &TenantCapabilityUpdateRequest{} + +// TenantCapabilityUpdateRequest Request data to enable or disable a scoped Tenant capability for a resolved set of Sites. An empty `sites` array applies the change to all eligible Sites (optionally filtered by `infrastructureProviderId`); a non-empty array scopes the change to the listed Sites. +type TenantCapabilityUpdateRequest struct { + // The capability to toggle + CapabilityName string `json:"capabilityName"` + // Site IDs to scope the change to. Empty array means all eligible Sites. + Sites []string `json:"sites,omitempty"` + // Optionally restrict the resolved Site set to Sites owned by this provider + InfrastructureProviderId *string `json:"infrastructureProviderId,omitempty"` + // true opts in, false opts out, for the resolved Sites + Enabled bool `json:"enabled"` +} + +type _TenantCapabilityUpdateRequest TenantCapabilityUpdateRequest + +// NewTenantCapabilityUpdateRequest instantiates a new TenantCapabilityUpdateRequest 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 NewTenantCapabilityUpdateRequest(capabilityName string, enabled bool) *TenantCapabilityUpdateRequest { + this := TenantCapabilityUpdateRequest{} + this.CapabilityName = capabilityName + this.Enabled = enabled + return &this +} + +// NewTenantCapabilityUpdateRequestWithDefaults instantiates a new TenantCapabilityUpdateRequest 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 NewTenantCapabilityUpdateRequestWithDefaults() *TenantCapabilityUpdateRequest { + this := TenantCapabilityUpdateRequest{} + return &this +} + +// GetCapabilityName returns the CapabilityName field value +func (o *TenantCapabilityUpdateRequest) GetCapabilityName() string { + if o == nil { + var ret string + return ret + } + + return o.CapabilityName +} + +// GetCapabilityNameOk returns a tuple with the CapabilityName field value +// and a boolean to check if the value has been set. +func (o *TenantCapabilityUpdateRequest) GetCapabilityNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.CapabilityName, true +} + +// SetCapabilityName sets field value +func (o *TenantCapabilityUpdateRequest) SetCapabilityName(v string) { + o.CapabilityName = v +} + +// GetSites returns the Sites field value if set, zero value otherwise. +func (o *TenantCapabilityUpdateRequest) GetSites() []string { + if o == nil || IsNil(o.Sites) { + var ret []string + return ret + } + return o.Sites +} + +// GetSitesOk returns a tuple with the Sites field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantCapabilityUpdateRequest) GetSitesOk() ([]string, bool) { + if o == nil || IsNil(o.Sites) { + return nil, false + } + return o.Sites, true +} + +// HasSites returns a boolean if a field has been set. +func (o *TenantCapabilityUpdateRequest) HasSites() bool { + if o != nil && !IsNil(o.Sites) { + return true + } + + return false +} + +// SetSites gets a reference to the given []string and assigns it to the Sites field. +func (o *TenantCapabilityUpdateRequest) SetSites(v []string) { + o.Sites = v +} + +// GetInfrastructureProviderId returns the InfrastructureProviderId field value if set, zero value otherwise. +func (o *TenantCapabilityUpdateRequest) GetInfrastructureProviderId() string { + if o == nil || IsNil(o.InfrastructureProviderId) { + var ret string + return ret + } + return *o.InfrastructureProviderId +} + +// GetInfrastructureProviderIdOk returns a tuple with the InfrastructureProviderId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *TenantCapabilityUpdateRequest) GetInfrastructureProviderIdOk() (*string, bool) { + if o == nil || IsNil(o.InfrastructureProviderId) { + return nil, false + } + return o.InfrastructureProviderId, true +} + +// HasInfrastructureProviderId returns a boolean if a field has been set. +func (o *TenantCapabilityUpdateRequest) HasInfrastructureProviderId() bool { + if o != nil && !IsNil(o.InfrastructureProviderId) { + return true + } + + return false +} + +// SetInfrastructureProviderId gets a reference to the given string and assigns it to the InfrastructureProviderId field. +func (o *TenantCapabilityUpdateRequest) SetInfrastructureProviderId(v string) { + o.InfrastructureProviderId = &v +} + +// GetEnabled returns the Enabled field value +func (o *TenantCapabilityUpdateRequest) GetEnabled() bool { + if o == nil { + var ret bool + return ret + } + + return o.Enabled +} + +// GetEnabledOk returns a tuple with the Enabled field value +// and a boolean to check if the value has been set. +func (o *TenantCapabilityUpdateRequest) GetEnabledOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.Enabled, true +} + +// SetEnabled sets field value +func (o *TenantCapabilityUpdateRequest) SetEnabled(v bool) { + o.Enabled = v +} + +func (o TenantCapabilityUpdateRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o TenantCapabilityUpdateRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["capabilityName"] = o.CapabilityName + if !IsNil(o.Sites) { + toSerialize["sites"] = o.Sites + } + if !IsNil(o.InfrastructureProviderId) { + toSerialize["infrastructureProviderId"] = o.InfrastructureProviderId + } + toSerialize["enabled"] = o.Enabled + return toSerialize, nil +} + +func (o *TenantCapabilityUpdateRequest) 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{ + "capabilityName", + "enabled", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varTenantCapabilityUpdateRequest := _TenantCapabilityUpdateRequest{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varTenantCapabilityUpdateRequest) + + if err != nil { + return err + } + + *o = TenantCapabilityUpdateRequest(varTenantCapabilityUpdateRequest) + + return err +} + +type NullableTenantCapabilityUpdateRequest struct { + value *TenantCapabilityUpdateRequest + isSet bool +} + +func (v NullableTenantCapabilityUpdateRequest) Get() *TenantCapabilityUpdateRequest { + return v.value +} + +func (v *NullableTenantCapabilityUpdateRequest) Set(val *TenantCapabilityUpdateRequest) { + v.value = val + v.isSet = true +} + +func (v NullableTenantCapabilityUpdateRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableTenantCapabilityUpdateRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableTenantCapabilityUpdateRequest(val *TenantCapabilityUpdateRequest) *NullableTenantCapabilityUpdateRequest { + return &NullableTenantCapabilityUpdateRequest{value: val, isSet: true} +} + +func (v NullableTenantCapabilityUpdateRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableTenantCapabilityUpdateRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +}