diff --git a/AGENTS.md b/AGENTS.md index ec489149eb..4b43f36674 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,6 +355,8 @@ check before requesting review. - global versus subcommand position and required order - omission or fallback behavior - observable output, side effects, errors, and unsupported paths + - for repeated or list fields, membership, ordering, and whether omitted and + empty values have the same meaning - Exercise each changed CLI example at the PR revision on an authorized local or test target and compare it with real `--help` output. Verify changed API, configuration, environment-variable, and state contracts through schemas, diff --git a/crates/api-core/src/handlers/tenant.rs b/crates/api-core/src/handlers/tenant.rs index d4fd7b18b0..5a8519f835 100644 --- a/crates/api-core/src/handlers/tenant.rs +++ b/crates/api-core/src/handlers/tenant.rs @@ -133,8 +133,46 @@ pub(crate) async fn find( .map(Response::new)? .into_inner() { - None => rpc::FindTenantResponse { tenant: None }, - Some(t) => t.try_into().map_err(CarbideError::from)?, + None => rpc::FindTenantResponse { + tenant: None, + permitted_routing_profile_types: vec![], + }, + Some(t) => { + let permitted_routing_profile_types = match ( + t.routing_profile_type.as_deref(), + api.runtime_config.fnn.as_ref(), + ) { + (Some(tenant_profile_type), Some(fnn)) => { + match fnn.routing_profiles.get(tenant_profile_type) { + Some(tenant_profile) => { + let tenant_access_tier = tenant_profile.access_tier.unwrap_or_default(); + let mut permitted = fnn + .routing_profiles + .iter() + .filter(|(_, profile)| { + profile.access_tier.unwrap_or_default() >= tenant_access_tier + }) + .map(|(name, _)| name.clone()) + .collect::>(); + permitted.sort(); + permitted + } + None => { + tracing::warn!( + organization_id = %t.organization_id, + %tenant_profile_type, + "tenant routing profile is not present in the current FNN config" + ); + vec![] + } + } + } + _ => vec![], + }; + let mut response: rpc::FindTenantResponse = t.try_into().map_err(CarbideError::from)?; + response.permitted_routing_profile_types = permitted_routing_profile_types; + response + } }; txn.commit().await?; diff --git a/crates/api-core/src/tests/tenants.rs b/crates/api-core/src/tests/tenants.rs index dfac29d453..0e7857f8dc 100644 --- a/crates/api-core/src/tests/tenants.rs +++ b/crates/api-core/src/tests/tenants.rs @@ -135,6 +135,10 @@ async fn test_tenant(pool: sqlx::PgPool) { .unwrap() .into_inner(); + assert_eq!( + find_tenant.permitted_routing_profile_types, + vec!["EXTERNAL"] + ); let tenant = find_tenant.tenant.unwrap(); // This fixture enables the default FNN config, so the tenant should @@ -338,6 +342,20 @@ async fn test_tenant(pool: sqlx::PgPool) { assert_eq!(tenant.routing_profile_type.as_deref(), Some("INTERNAL")); + let find_tenant = env + .api + .find_tenant(tonic::Request::new(rpc::forge::FindTenantRequest { + tenant_organization_id: "Org".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!( + find_tenant.permitted_routing_profile_types, + vec!["EXTERNAL", "INTERNAL"] + ); + // Now perform one more good create just to confirm that we can set // the routing profile to something other than default let tenant_create = env @@ -359,6 +377,31 @@ async fn test_tenant(pool: sqlx::PgPool) { assert_eq!(tenant.routing_profile_type.as_deref(), Some("INTERNAL")); assert_eq!(tenant.organization_id, "Org2"); + + // A profile can disappear from FNN config after it was persisted. Tenant + // lookup must remain usable and expose no selectable profiles in that + // stale state. + sqlx::query("UPDATE tenants SET routing_profile_type = $1 WHERE organization_id = $2") + .bind("REMOVED_PROFILE") + .bind("Org2") + .execute(&env.pool) + .await + .unwrap(); + + let find_tenant = env + .api + .find_tenant(tonic::Request::new(rpc::forge::FindTenantRequest { + tenant_organization_id: "Org2".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert!(find_tenant.permitted_routing_profile_types.is_empty()); + assert_eq!( + find_tenant.tenant.unwrap().routing_profile_type.as_deref(), + Some("REMOVED_PROFILE") + ); } #[crate::sqlx_test] @@ -470,6 +513,7 @@ async fn test_tenant_create_without_fnn(pool: sqlx::PgPool) { .unwrap() .into_inner(); + assert!(find_tenant.permitted_routing_profile_types.is_empty()); let tenant = find_tenant.tenant.unwrap(); assert_eq!(tenant.organization_id, "PreFnnOrg"); assert_eq!(tenant.routing_profile_type, None); diff --git a/crates/rpc/proto/forge.proto b/crates/rpc/proto/forge.proto index b65316a8d4..d56039f7d8 100644 --- a/crates/rpc/proto/forge.proto +++ b/crates/rpc/proto/forge.proto @@ -6067,6 +6067,12 @@ message FindTenantRequest { } message FindTenantResponse { Tenant tenant = 1; + // Named VPC routing profiles whose access tier is permitted for this Tenant. + // Omitted and empty are equivalent. The list is empty when the Tenant, FNN, + // or the Tenant's configured routing profile is unavailable. Otherwise it + // includes that profile and every profile with an equal or higher access + // tier, sorted by profile name. + repeated string permitted_routing_profile_types = 2; } message TenantKeysetIdentifier { diff --git a/crates/rpc/src/model/tenant.rs b/crates/rpc/src/model/tenant.rs index 77ccb6f736..b4d79a83a7 100644 --- a/crates/rpc/src/model/tenant.rs +++ b/crates/rpc/src/model/tenant.rs @@ -78,6 +78,7 @@ impl TryFrom for rpc::forge::FindTenantResponse { fn try_from(value: Tenant) -> Result { Ok(rpc::forge::FindTenantResponse { tenant: Some(value.try_into()?), + permitted_routing_profile_types: vec![], }) } } diff --git a/rest-api/AGENTS.md b/rest-api/AGENTS.md index 95f6397708..2ee5d1e489 100644 --- a/rest-api/AGENTS.md +++ b/rest-api/AGENTS.md @@ -254,6 +254,10 @@ verification expectations. passwords and other credentials. Keep OpenAPI descriptions focused on the REST contract rather than internal gRPC implementation details. +- When an authoritative external create returns a contract-critical value, + persist it in the request transaction before returning 2xx and independently + assert the response and database state. Do not rely on a best-effort cache or + later reconciliation for read-after-create behavior. - API-layer enum-like request constants exposed through JSON use CapitalCase values, for example `SiteWideRoot` and `BMCRoot`. - When prose names exact API enum values, format the literals as code, for @@ -283,7 +287,9 @@ verification expectations. backslashes, control characters, non-ASCII text, and shell metacharacters. - When a mutation success message reads fields from a response object, reject malformed JSON, `null`, empty objects, and missing display fields before - printing success. + printing success. Use the returned resource values rather than echoing + request or discovery values that the server may default or normalize, and + test a response whose value differs from the pre-request value. ### REST endpoints through the Core gRPC proxy diff --git a/rest-api/api/pkg/api/handler/tenant.go b/rest-api/api/pkg/api/handler/tenant.go index 3314441ea6..2270d91a00 100644 --- a/rest-api/api/pkg/api/handler/tenant.go +++ b/rest-api/api/pkg/api/handler/tenant.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" + "github.com/google/uuid" temporalClient "go.temporal.io/sdk/client" "github.com/rs/zerolog" @@ -21,8 +22,10 @@ import ( "github.com/NVIDIA/infra-controller/rest-api/api/internal/config" "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site" auth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization" cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1" ) // ~~~~~ Create Handler ~~~~~ // @@ -219,6 +222,125 @@ func (gcth GetCurrentTenantHandler) Handle(c echo.Context) error { return c.JSON(http.StatusOK, apiInstance) } +// ~~~~~ Get Current Routing Profile Handler ~~~~~ // + +// GetCurrentTenantRoutingProfileHandler retrieves the routing profiles the +// current Tenant may use at one Site. +type GetCurrentTenantRoutingProfileHandler struct { + dbSession *cdb.Session + scp *sc.ClientPool + tracerSpan *cutil.TracerSpan +} + +// NewGetCurrentTenantRoutingProfileHandler initializes the routing-profile handler. +func NewGetCurrentTenantRoutingProfileHandler(dbSession *cdb.Session, scp *sc.ClientPool) GetCurrentTenantRoutingProfileHandler { + return GetCurrentTenantRoutingProfileHandler{ + dbSession: dbSession, + scp: scp, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Retrieve current Tenant routing profiles for a Site +// @Description Retrieve the Tenant's default VPC routing profile and the profiles it may select at one Site. +// @Tags tenant +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param siteId query string true "ID of Site" +// @Success 200 {object} model.APITenantRoutingProfile +// @Router /v2/org/{org}/nico/tenant/current/routing-profile [get] +func (gctrph GetCurrentTenantRoutingProfileHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("TenantRoutingProfile", "GetCurrent", c, gctrph.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + if dbUser == nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + 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) + } + + ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) + if !ok { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + } + + siteID := c.QueryParam("siteId") + site, err := common.GetSiteFromIDString(ctx, nil, siteID, gctrph.dbSession) + if err != nil { + if err == cdb.ErrDoesNotExist { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Could not find Site with ID specified in query", nil) + } + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Site ID in query", nil) + } + if site.Status != cdbm.SiteStatusRegistered { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Site specified in query must be in Registered state", nil) + } + if site.Config == nil || !site.Config.NativeNetworking { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Site specified in query must have native networking enabled", nil) + } + + tenant, err := common.GetTenantForOrg(ctx, nil, gctrph.dbSession, org) + if err != nil { + if err == common.ErrOrgTenantNotFound { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Org does not have a Tenant associated", nil) + } + logger.Error().Err(err).Msg("error retrieving Tenant for this org") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant", nil) + } + + allocationDAO := cdbm.NewAllocationDAO(gctrph.dbSession) + allocationCount, err := allocationDAO.GetCount(ctx, nil, cdbm.AllocationFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + SiteIDs: []uuid.UUID{site.ID}, + }) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Allocations count from DB for Tenant and Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site Allocations count for Tenant", nil) + } + if allocationCount == 0 { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant does not have any Allocations with Site specified in query", nil) + } + + stc, err := gctrph.scp.GetClientByID(site.ID) + if err != nil { + logger.Error().Err(err).Msg("failed to retrieve Temporal client for Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve client for Site", nil) + } + + coreResponse := &corev1.FindTenantResponse{} + apiErr := common.ExecuteCoreGRPC(ctx, stc, corev1.Forge_FindTenant_FullMethodName, &corev1.FindTenantRequest{ + TenantOrganizationId: org, + }, coreResponse, site.ID.String()) + if apiErr != nil { + logAPIError(logger, apiErr, "failed to retrieve Tenant routing profiles") + return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, nil) + } + if coreResponse.GetTenant() == nil { + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Tenant was not found on Site", nil) + } + + allowAlternatives, err := common.TenantHasTargetedInstanceCreation(ctx, nil, gctrph.dbSession, tenant, &common.TenantPrivilegeScope{SiteID: &site.ID}) + if err != nil { + logger.Error().Err(err).Msg("error resolving TargetedInstanceCreation for Tenant/Site") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify privileges for Site", nil) + } + + response := &model.APITenantRoutingProfile{} + response.FromProto(coreResponse, allowAlternatives) + return c.JSON(http.StatusOK, response) +} + // ~~~~~ Get Current Stats Handler ~~~~~ // // GetCurrentTenantStatsHandler is the API Handler for retrieving Tenant stats associated with the org diff --git a/rest-api/api/pkg/api/handler/vpc.go b/rest-api/api/pkg/api/handler/vpc.go index c28be46f8b..5d8afb8448 100644 --- a/rest-api/api/pkg/api/handler/vpc.go +++ b/rest-api/api/pkg/api/handler/vpc.go @@ -368,6 +368,7 @@ func (cvh CreateVPCHandler) Handle(c echo.Context) error { var vpc *cdbm.Vpc var ssd *cdbm.StatusDetail controllerVpc := &corev1.Vpc{} + controllerVpcModel := &cdbm.Vpc{} // timeoutResp lets the closure signal a post-rollback handler — the // TerminateWorkflow call has to run after the closure returns so that @@ -477,6 +478,19 @@ func (cvh CreateVPCHandler) Handle(c echo.Context) error { return cutil.NewAPIError(code, fmt.Sprintf("Failed to execute sync workflow to create VPC on Site: %s", unwrapped), nil) } + controllerVpcModel.FromProto(controllerVpc) + if controllerVpcModel.RoutingProfile != nil { + updatedVpc, derr := vpcDAO.Update(ctx, tx, cdbm.VpcUpdateInput{ + VpcID: vpc.ID, + RoutingProfile: controllerVpcModel.RoutingProfile, + }) + if derr != nil { + logger.Error().Err(derr).Msg("error persisting Core-resolved VPC routing profile") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to persist Core-resolved VPC routing profile", nil) + } + vpc = updatedVpc + } + logger.Info().Str("Workflow ID", wid).Msg("completed synchronous create VPC workflow") return nil }) @@ -509,10 +523,8 @@ func (cvh CreateVPCHandler) Handle(c echo.Context) error { statusDetails := []cdbm.StatusDetail{*ssd} - // Make a best-effort attempt to cache the controller-reported VNI and - // effective routing profile for the response. - controllerVpcModel := &cdbm.Vpc{} - controllerVpcModel.FromProto(controllerVpc) + // Make a best-effort attempt to cache the remaining controller-reported + // state for the response. The resolved routing profile was committed above. activeVni := controllerVpcModel.ActiveVni effectiveRoutingProfile := controllerVpcModel.EffectiveRoutingProfile if activeVni != nil || effectiveRoutingProfile != nil { @@ -526,7 +538,7 @@ func (cvh CreateVPCHandler) Handle(c echo.Context) error { } updatedVpc, err := vpcDAO.Update(ctx, nil, uvpcInput) if err != nil { - logger.Error().Err(err).Msg("error while updating VPC DB entry for VNI") + logger.Error().Err(err).Msg("error while caching controller-reported VPC VNI and effective routing profile") } else { // Update the vpc being returned if all went well. vpc = updatedVpc diff --git a/rest-api/api/pkg/api/handler/vpc_test.go b/rest-api/api/pkg/api/handler/vpc_test.go index 1718c73ae2..152a402957 100644 --- a/rest-api/api/pkg/api/handler/vpc_test.go +++ b/rest-api/api/pkg/api/handler/vpc_test.go @@ -342,6 +342,7 @@ func TestCreateVPCHandler_Handle(t *testing.T) { expectedStatus string expectedVni *int expectedVirtualizationType string + expectedRoutingProfile *string expectedStatusDetails []expectedStatusDetail } @@ -476,6 +477,15 @@ func TestCreateVPCHandler_Handle(t *testing.T) { vpcWithAllocatedVniName := "Test VPC with allocated VNI" vpcWithRoutingProfileName := "Test VPC routing profile" vpcWithRoutingProfileOverridesName := "Test VPC routing profile overrides" + vpcWithResolvedRoutingProfileName := "Test VPC resolved routing profile" + vpcWithUnpersistedResolvedRoutingProfileName := "Test VPC unpersisted resolved routing profile" + vpcWithUnpersistedResolvedRoutingProfileID := uuid.New() + _, err = dbSession.DB.Exec(` + ALTER TABLE vpc + ADD CONSTRAINT vpc_test_reject_resolved_routing_profile_persistence + CHECK (name <> 'Test VPC unpersisted resolved routing profile' OR routing_profile IS NULL) + `) + require.NoError(t, err) allocatedVni := uint32(7301) expectedAllocatedVni := int(allocatedVni) @@ -511,6 +521,17 @@ func TestCreateVPCHandler_Handle(t *testing.T) { } }).Return(nil) + wrunWithResolvedRoutingProfile := &tmocks.WorkflowRun{} + wrunWithResolvedRoutingProfile.On("GetID").Return(wid) + wrunWithResolvedRoutingProfile.Mock.On("Get", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + controllerVpc, ok := args.Get(1).(*corev1.Vpc) + if ok { + controllerVpc.Config = &corev1.VpcConfig{ + RoutingProfileType: cutil.GetPtr("EXTERNAL"), + } + } + }).Return(nil) + tc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), mock.AnythingOfType("func(internal.Context, uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("uuid.UUID"), mock.AnythingOfType("uuid.UUID")).Return(wrun, nil) @@ -540,7 +561,12 @@ func TestCreateVPCHandler_Handle(t *testing.T) { tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), "CreateVPCV2", mock.MatchedBy(func(req *corev1.VpcCreationRequest) bool { - return req == nil || (req.Name != unavailableVpcName && req.Name != vpcWithAllocatedVniName && req.Name != vpcWithRoutingProfileName && req.Name != vpcWithRoutingProfileOverridesName) + return req != nil && (req.Name == vpcWithResolvedRoutingProfileName || req.Name == vpcWithUnpersistedResolvedRoutingProfileName) + })).Return(wrunWithResolvedRoutingProfile, nil) + + tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), + "CreateVPCV2", mock.MatchedBy(func(req *corev1.VpcCreationRequest) bool { + return req == nil || (req.Name != unavailableVpcName && req.Name != vpcWithAllocatedVniName && req.Name != vpcWithRoutingProfileName && req.Name != vpcWithRoutingProfileOverridesName && req.Name != vpcWithResolvedRoutingProfileName && req.Name != vpcWithUnpersistedResolvedRoutingProfileName) })).Return(wrun, nil) // Mock timeout error @@ -656,6 +682,58 @@ func TestCreateVPCHandler_Handle(t *testing.T) { wantErr: false, verifyChildSpanner: true, }, + { + name: "test VPC create API endpoint returns Core-resolved routing profile", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIVpcCreateRequest{ + Name: vpcWithResolvedRoutingProfileName, + SiteID: st1.ID.String(), + NetworkVirtualizationType: cutil.GetPtr(cdbm.VpcFNN), + }, + reqOrg: tnOrg, + reqUser: tnu, + respCode: http.StatusCreated, + expectedStatus: cdbm.VpcStatusProvisioning, + expectedRoutingProfile: cutil.GetPtr(model.APIVpcRoutingProfileExternal), + expectedStatusDetails: []expectedStatusDetail{ + { + status: cdbm.VpcStatusProvisioning, + message: "VPC provisioning has been initiated on Site", + }, + }, + }, + wantErr: false, + verifyChildSpanner: true, + }, + { + name: "test VPC create API endpoint rolls back when Core-resolved routing profile cannot be persisted", + fields: fields{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + }, + args: args{ + reqData: &model.APIVpcCreateRequest{ + ID: &vpcWithUnpersistedResolvedRoutingProfileID, + Name: vpcWithUnpersistedResolvedRoutingProfileName, + SiteID: st1.ID.String(), + NetworkVirtualizationType: cutil.GetPtr(cdbm.VpcFNN), + SlaacEnabled: cutil.GetPtr(true), + }, + reqOrg: tnOrg, + reqUser: tnu, + respCode: http.StatusInternalServerError, + respMessage: "Failed to persist Core-resolved VPC routing profile", + }, + wantErr: false, + verifyChildSpanner: true, + expectRolledBack: true, + }, { name: "test VPC create API endpoint rejects SLAAC when Site config inventory stores false", fields: fields{ @@ -897,7 +975,7 @@ func TestCreateVPCHandler_Handle(t *testing.T) { verifyChildSpanner: true, }, { - name: "test VPC create API endpoint rejects unsupported routing profile", + name: "test VPC create API endpoint accepts site-configured routing profile", fields: fields{ dbSession: dbSession, tc: tc, @@ -905,16 +983,22 @@ func TestCreateVPCHandler_Handle(t *testing.T) { }, args: args{ reqData: &model.APIVpcCreateRequest{ - Name: "Test VPC unsupported routing profile", + Name: "Test VPC site-configured routing profile", Description: cutil.GetPtr("Test VPC Description"), SiteID: st1.ID.String(), NetworkVirtualizationType: cutil.GetPtr(cdbm.VpcFNN), RoutingProfile: cutil.GetPtr("tenant-edge"), }, - reqOrg: tnOrg, - reqUser: tnu, - respCode: http.StatusBadRequest, - respMessage: "`routingProfile` must be one of privileged-internal, internal, or external", + reqOrg: tnOrg, + reqUser: tnu, + respCode: http.StatusCreated, + expectedStatus: cdbm.VpcStatusProvisioning, + expectedStatusDetails: []expectedStatusDetail{ + { + status: cdbm.VpcStatusProvisioning, + message: "VPC provisioning has been initiated on Site", + }, + }, }, wantErr: false, verifyChildSpanner: true, @@ -1482,7 +1566,11 @@ func TestCreateVPCHandler_Handle(t *testing.T) { } else { assert.Nil(t, rst.Description) } - assert.Equal(t, tt.args.reqData.RoutingProfile, rst.RoutingProfile) + expectedRoutingProfile := tt.args.reqData.RoutingProfile + if tt.args.expectedRoutingProfile != nil { + expectedRoutingProfile = tt.args.expectedRoutingProfile + } + assert.Equal(t, expectedRoutingProfile, rst.RoutingProfile) assert.Equal(t, tt.args.reqData.RoutingProfileOverrides, rst.RoutingProfileOverrides) expectedSlaacEnabled := tt.args.reqData.SlaacEnabled != nil && *tt.args.reqData.SlaacEnabled assert.Equal(t, expectedSlaacEnabled, rst.SlaacEnabled) @@ -1527,6 +1615,12 @@ func TestCreateVPCHandler_Handle(t *testing.T) { assert.Equal(t, expectedSlaacEnabled, persistedVpc.SlaacEnabled) require.NotNil(t, persistedVpc.NetworkVirtualizationType) assert.Equal(t, expectedVirtualizationType, *persistedVpc.NetworkVirtualizationType) + if expectedRoutingProfile != nil { + require.NotNil(t, persistedVpc.RoutingProfile) + assert.Equal(t, model.NormalizeAPIVpcRoutingProfileForSite(*expectedRoutingProfile), *persistedVpc.RoutingProfile) + } else { + assert.Nil(t, persistedVpc.RoutingProfile) + } assert.Equal(t, tt.args.reqData.RoutingProfileOverrides.ToDB(), persistedVpc.RoutingProfileOverrides) if tt.args.reqData.RoutingProfileOverrides != nil { // Effective state returned without a VNI is cached and exposed to this privileged tenant. diff --git a/rest-api/api/pkg/api/model/tenant.go b/rest-api/api/pkg/api/model/tenant.go index e7bccc6f03..8f50839726 100644 --- a/rest-api/api/pkg/api/model/tenant.go +++ b/rest-api/api/pkg/api/model/tenant.go @@ -8,6 +8,7 @@ import ( cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1" ) var ( @@ -50,6 +51,38 @@ type APITenant struct { Deprecations []APIDeprecation `json:"deprecations"` } +// APITenantRoutingProfile describes the routing profiles a Tenant may select +// for VPC creation at one Site. +type APITenantRoutingProfile struct { + // DefaultRoutingProfile is the profile Core applies when a VPC omits routingProfile. + DefaultRoutingProfile string `json:"defaultRoutingProfile"` + // PermittedRoutingProfiles contains the profiles this Tenant may choose during VPC creation. + // Choosing DefaultRoutingProfile inherits it without sending an explicit override. + PermittedRoutingProfiles []string `json:"permittedRoutingProfiles"` +} + +// FromProto populates the REST response from Core's Tenant lookup. When the +// Tenant lacks the site-scoped write privilege, only its inheritable default is +// exposed as selectable. +func (atrp *APITenantRoutingProfile) FromProto(response *corev1.FindTenantResponse, allowAlternatives bool) { + *atrp = APITenantRoutingProfile{PermittedRoutingProfiles: []string{}} + if response == nil || response.GetTenant() == nil { + return + } + + atrp.DefaultRoutingProfile = NormalizeAPIVpcRoutingProfileFromSite(response.GetTenant().GetRoutingProfileType()) + if !allowAlternatives { + if atrp.DefaultRoutingProfile != "" { + atrp.PermittedRoutingProfiles = append(atrp.PermittedRoutingProfiles, atrp.DefaultRoutingProfile) + } + return + } + + for _, profile := range response.GetPermittedRoutingProfileTypes() { + atrp.PermittedRoutingProfiles = append(atrp.PermittedRoutingProfiles, NormalizeAPIVpcRoutingProfileFromSite(profile)) + } +} + // NewAPITenant accepts a DB layer Tenant object and the deprecated tenant-wide // TargetedInstanceCreation compatibility value, then returns an API layer object. func NewAPITenant(dbtn *cdbm.Tenant, targetedInstanceCreation bool) *APITenant { diff --git a/rest-api/api/pkg/api/model/tenant_test.go b/rest-api/api/pkg/api/model/tenant_test.go index 83661ffa18..053a0410a1 100644 --- a/rest-api/api/pkg/api/model/tenant_test.go +++ b/rest-api/api/pkg/api/model/tenant_test.go @@ -10,6 +10,7 @@ import ( cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -69,6 +70,38 @@ func TestNewAPITenant(t *testing.T) { } } +func TestAPITenantRoutingProfile_FromProto(t *testing.T) { + coreResponse := &corev1.FindTenantResponse{ + Tenant: &corev1.Tenant{RoutingProfileType: cutil.GetPtr("INTERNAL")}, + PermittedRoutingProfileTypes: []string{"EXTERNAL", "INTERNAL", "MAINTENANCE"}, + } + + t.Run("allows site-scoped alternatives", func(t *testing.T) { + response := &APITenantRoutingProfile{} + response.FromProto(coreResponse, true) + + assert.Equal(t, "internal", response.DefaultRoutingProfile) + assert.Equal(t, []string{"external", "internal", "MAINTENANCE"}, response.PermittedRoutingProfiles) + }) + + t.Run("limits selection to tenant default without privilege", func(t *testing.T) { + response := &APITenantRoutingProfile{} + response.FromProto(coreResponse, false) + + assert.Equal(t, "internal", response.DefaultRoutingProfile) + assert.Equal(t, []string{"internal"}, response.PermittedRoutingProfiles) + }) + + t.Run("keeps response keys stable for missing tenant", func(t *testing.T) { + response := &APITenantRoutingProfile{} + response.FromProto(&corev1.FindTenantResponse{}, true) + + body, err := json.Marshal(response) + require.NoError(t, err) + assert.JSONEq(t, `{"defaultRoutingProfile":"","permittedRoutingProfiles":[]}`, string(body)) + }) +} + func TestNewAPITenantSummary(t *testing.T) { dbtn := &cdbm.Tenant{ ID: uuid.New(), diff --git a/rest-api/api/pkg/api/model/vpc.go b/rest-api/api/pkg/api/model/vpc.go index 94837b95f4..6e30c78289 100644 --- a/rest-api/api/pkg/api/model/vpc.go +++ b/rest-api/api/pkg/api/model/vpc.go @@ -56,7 +56,9 @@ func NormalizeAPIVpcRoutingProfileForSite(routingProfile string) string { return routingProfile } -func normalizeAPIVpcRoutingProfileFromSite(routingProfile string) string { +// NormalizeAPIVpcRoutingProfileFromSite converts known site-controller routing +// profile values to the REST API spelling. +func NormalizeAPIVpcRoutingProfileFromSite(routingProfile string) string { if mapped, ok := apiVpcRoutingProfileFromSiteMap[routingProfile]; ok { return mapped } @@ -282,8 +284,8 @@ type APIVpcCreateRequest struct { // RoutingProfile specifies the routing profile for the VPC. // This is only supported when `networkVirtualizationType` is `FNN`, or when // `networkVirtualizationType` is omitted and the Site has native networking enabled. - // This requires the Tenant to have elevated privileges. Current accepted values - // are `privileged-internal`, `internal`, and `external`. + // This requires the Tenant to have elevated privileges. The selected value must + // be one of the Site-configured profiles returned for the Tenant. RoutingProfile *string `json:"routingProfile"` // RoutingProfileOverrides replaces selected properties from the VPC's named routing profile. RoutingProfileOverrides *APIVpcRoutingProfileOverrides `json:"routingProfileOverrides"` @@ -334,12 +336,6 @@ func (ascr APIVpcCreateRequest) Validate() error { } if ascr.RoutingProfile != nil { - if _, ok := apiVpcRoutingProfileToSiteMap[*ascr.RoutingProfile]; !ok { - return validation.Errors{ - "routingProfile": fmt.Errorf("`routingProfile` must be one of %s, %s, or %s", APIVpcRoutingProfilePrivilegedInternal, APIVpcRoutingProfileInternal, APIVpcRoutingProfileExternal), - } - } - if ascr.NetworkVirtualizationType != nil && !cdbm.VpcTypeSupportsRoutingProfile(ascr.NetworkVirtualizationType) { return validation.Errors{ "routingProfile": errors.New("`routingProfile` is only supported when `networkVirtualizationType` is FNN"), @@ -600,7 +596,7 @@ func NewAPIVpc(dbVpc cdbm.Vpc, dbsds []cdbm.StatusDetail, includeEffectiveRoutin } if dbVpc.RoutingProfile != nil { - routingProfile := normalizeAPIVpcRoutingProfileFromSite(*dbVpc.RoutingProfile) + routingProfile := NormalizeAPIVpcRoutingProfileFromSite(*dbVpc.RoutingProfile) apivpc.RoutingProfile = &routingProfile } diff --git a/rest-api/api/pkg/api/model/vpc_test.go b/rest-api/api/pkg/api/model/vpc_test.go index 8d17b28a06..ab02ea9f90 100644 --- a/rest-api/api/pkg/api/model/vpc_test.go +++ b/rest-api/api/pkg/api/model/vpc_test.go @@ -186,14 +186,14 @@ func TestAPIVpcCreateRequest_Validate(t *testing.T) { wantErr: true, }, { - name: "test invalid VPC create request - routing profile is unsupported", + name: "test valid VPC create request - site-configured routing profile", fields: fields{ Name: "test-name", SiteID: uuid.NewString(), NetworkVirtualizationType: cutil.GetPtr(cdbm.VpcFNN), RoutingProfile: cutil.GetPtr("tenant-edge"), }, - wantErr: true, + wantErr: false, }, { name: "test invalid VPC create request - invalid VNI", diff --git a/rest-api/api/pkg/api/routes.go b/rest-api/api/pkg/api/routes.go index 0dcfabc631..9bd901c18f 100644 --- a/rest-api/api/pkg/api/routes.go +++ b/rest-api/api/pkg/api/routes.go @@ -158,6 +158,11 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa Method: http.MethodGet, Handler: apiHandler.NewGetCurrentTenantStatsHandler(dbSession, tc, cfg), }, + { + Path: apiPathPrefix + "/tenant/current/routing-profile", + Method: http.MethodGet, + Handler: apiHandler.NewGetCurrentTenantRoutingProfileHandler(dbSession, scp), + }, // Tenant Instance Type Stats endpoint { Path: apiPathPrefix + "/tenant/instance-type/stats", diff --git a/rest-api/api/pkg/api/routes_test.go b/rest-api/api/pkg/api/routes_test.go index 216c0255c4..2f205715fa 100644 --- a/rest-api/api/pkg/api/routes_test.go +++ b/rest-api/api/pkg/api/routes_test.go @@ -40,7 +40,7 @@ func TestNewAPIRoutes(t *testing.T) { "site-explorer": 2, "service-account": 1, "infrastructure-provider": 4, - "tenant": 4, + "tenant": 5, "tenant-account": 5, "site": 6, "vpc": 6, @@ -136,6 +136,8 @@ func TestNewAPIRoutes(t *testing.T) { taskPath := "/org/:orgName/" + cfg.GetAPIName() + "/task" assertRouteExists(t, got, http.MethodGet, taskPath) assertRouteBefore(t, got, http.MethodGet, taskPath, http.MethodGet, taskPath+"/:id") + tenantPath := "/org/:orgName/" + cfg.GetAPIName() + "/tenant" + assertRouteExists(t, got, http.MethodGet, tenantPath+"/current/routing-profile") machineAdminPath := "/org/:orgName/" + cfg.GetAPIName() + "/machine/:id" dpuPath := "/org/:orgName/" + cfg.GetAPIName() + "/dpu" diff --git a/rest-api/cli/tui/commands.go b/rest-api/cli/tui/commands.go index 445614348d..cb0230efd1 100644 --- a/rest-api/cli/tui/commands.go +++ b/rest-api/cli/tui/commands.go @@ -638,7 +638,43 @@ func cmdVPCCreate(s *Session, _ []string) error { if strings.TrimSpace(desc) != "" { body["description"] = desc } - LogCmd(s, "vpc", "create", "--name", name, "--site-id", site.ID) + + routingProfile := "" + routingProfileOverride := "" + siteRaw, _ := site.Raw.(map[string]interface{}) + siteCapabilities, _ := siteRaw["capabilities"].(map[string]interface{}) + nativeNetworking, _ := siteCapabilities["nativeNetworking"].(bool) + if nativeNetworking { + routingProfileResponse, _, requestErr := s.Client.Do("GET", apiPath(s, "tenant/current/routing-profile"), nil, map[string]string{"siteId": site.ID}, nil) + if requestErr != nil { + return fmt.Errorf("fetching Tenant routing profiles: %w", requestErr) + } + var tenantRoutingProfile struct { + DefaultRoutingProfile string `json:"defaultRoutingProfile"` + PermittedRoutingProfiles []string `json:"permittedRoutingProfiles"` + } + if err := json.Unmarshal(routingProfileResponse, &tenantRoutingProfile); err != nil { + return fmt.Errorf("parsing Tenant routing profiles: %w", err) + } + routingProfile, err = PromptChoice( + fmt.Sprintf("Routing profile (%s (tenant default))", tenantRoutingProfile.DefaultRoutingProfile), + tenantRoutingProfile.PermittedRoutingProfiles, + tenantRoutingProfile.DefaultRoutingProfile, + ) + if err != nil { + return err + } + if routingProfile != tenantRoutingProfile.DefaultRoutingProfile { + routingProfileOverride = routingProfile + body["routingProfile"] = routingProfileOverride + } + } + + logArgs := []string{"vpc", "create", "--name", name, "--site-id", site.ID} + if routingProfileOverride != "" { + logArgs = append(logArgs, "--routing-profile", routingProfileOverride) + } + LogCmd(s, logArgs...) bodyJSON, _ := json.Marshal(body) resp, _, err := s.Client.Do("POST", apiPath(s, "vpc"), nil, nil, bodyJSON) if err != nil { @@ -649,7 +685,12 @@ func cmdVPCCreate(s *Session, _ []string) error { if err != nil { return err } - fmt.Printf("%s VPC created: %s (%s)\n", Green("OK"), str(created, "name"), str(created, "id")) + resolvedRoutingProfile := str(created, "routingProfile") + if resolvedRoutingProfile != "" { + fmt.Printf("%s VPC created: %s (%s), routing profile: %s\n", Green("OK"), str(created, "name"), str(created, "id"), resolvedRoutingProfile) + } else { + fmt.Printf("%s VPC created: %s (%s)\n", Green("OK"), str(created, "name"), str(created, "id")) + } return nil } diff --git a/rest-api/cli/tui/regression_specialized_test.go b/rest-api/cli/tui/regression_specialized_test.go index c8ae526d0b..872f9d66da 100644 --- a/rest-api/cli/tui/regression_specialized_test.go +++ b/rest-api/cli/tui/regression_specialized_test.go @@ -498,6 +498,100 @@ func TestCmdInstanceUpdate_SendsAttributeOnlyPatch(t *testing.T) { assert.Contains(t, output, "Instance updated: new-name (instance-1)") } +func TestCmdVPCCreate(t *testing.T) { + tests := []struct { + name string + routingProfileResponse string + createResponse string + input string + expectedBody string + expectedLog string + unexpectedLog string + expectedConfirmation string + }{ + { + name: "sends a selected alternative profile", + routingProfileResponse: `{"defaultRoutingProfile":"external","permittedRoutingProfiles":["external","internal"]}`, + createResponse: `{"id":"vpc-1","name":"profile-vpc","routingProfile":"internal"}`, + input: "profile-vpc\n\ninternal\n", + expectedBody: `{"name":"profile-vpc","routingProfile":"internal","siteId":"site-1"}`, + expectedLog: "--routing-profile internal", + expectedConfirmation: "routing profile: internal", + }, + { + name: "reports the Core-resolved profile when the inherited default changes", + routingProfileResponse: `{"defaultRoutingProfile":"external","permittedRoutingProfiles":["external"]}`, + createResponse: `{"id":"vpc-1","name":"profile-vpc","routingProfile":"internal"}`, + input: "profile-vpc\n\n\n", + expectedBody: `{"name":"profile-vpc","siteId":"site-1"}`, + unexpectedLog: "--routing-profile", + expectedConfirmation: "routing profile: internal", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var mu sync.Mutex + requests := []specializedRequestSnapshot{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + mu.Lock() + requests = append(requests, specializedRequestSnapshot{ + method: r.Method, + path: r.URL.Path, + query: r.URL.RawQuery, + body: string(body), + }) + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/org/acme/nico/tenant/current/routing-profile": + _, _ = io.WriteString(w, test.routingProfileResponse) + case "/v2/org/acme/nico/vpc": + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, test.createResponse) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + session := NewSession(appcli.NewClient(server.URL, "acme", "token", nil, false), "acme", "") + session.Cache.Set("site", []NamedItem{{ + Name: "native-site", + ID: "site-1", + Raw: map[string]interface{}{"capabilities": map[string]interface{}{"nativeNetworking": true}}, + }}) + + output, runErr := runSpecializedCommandWithInput(t, test.input, func() error { + return specializedRegressionCommand(t, "vpc create").Run(session, nil) + }) + + require.NoError(t, runErr) + mu.Lock() + got := append([]specializedRequestSnapshot(nil), requests...) + mu.Unlock() + require.Len(t, got, 2) + assert.Equal(t, http.MethodGet, got[0].method) + assert.Equal(t, "/v2/org/acme/nico/tenant/current/routing-profile", got[0].path) + assert.Equal(t, "siteId=site-1", got[0].query) + assert.Equal(t, http.MethodPost, got[1].method) + assert.Equal(t, "/v2/org/acme/nico/vpc", got[1].path) + assert.JSONEq(t, test.expectedBody, got[1].body) + assert.Contains(t, output, "Routing profile (external (tenant default))") + if test.expectedLog != "" { + assert.Contains(t, output, test.expectedLog) + } + if test.unexpectedLog != "" { + assert.NotContains(t, output, test.unexpectedLog) + } + assert.Contains(t, output, test.expectedConfirmation) + }) + } +} + func specializedRegressionCommand(t *testing.T, name string) Command { t.Helper() for _, command := range AllCommands() { diff --git a/rest-api/docs/index.html b/rest-api/docs/index.html index 9e120eadd2..1cd2a9cc2f 100644 --- a/rest-api/docs/index.html +++ b/rest-api/docs/index.html @@ -464,7 +464,7 @@ -
Typical API Call Flow for Tenant " class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG">

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

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "org": "qygdmg8oqik8",
  • "orgDisplayName": "Echo Corporation",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z",
  • "capabilities": {
    },
  • "deprecations": [
    ]
}

Retrieve Stats for current Tenant

https://nico-rest-api.nico.svc.cluster.local/v2/org/{org}/nico/tenant/current

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "org": "qygdmg8oqik8",
  • "orgDisplayName": "Echo Corporation",
  • "created": "2019-08-24T14:15:22Z",
  • "updated": "2019-08-24T14:15:22Z",
  • "capabilities": {
    },
  • "deprecations": [
    ]
}

Retrieve Stats for current Tenant

Retrieve stats for current Tenant.

User must have authorization role with TENANT_ADMIN suffix.

@@ -900,7 +900,39 @@

Typical API Call Flow for Tenant

" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG">

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

Response samples

Content type
application/json
{
  • "instance": {
    },
  • "vpc": {
    },
  • "subnet": {
    },
  • "tenantAccount": {
    }
}

Retrieve per-tenant instance type allocation stats for a site

https://nico-rest-api.nico.svc.cluster.local/v2/org/{org}/nico/tenant/current/stats

Response samples

Content type
application/json
{
  • "instance": {
    },
  • "vpc": {
    },
  • "subnet": {
    },
  • "tenantAccount": {
    }
}

Retrieve VPC routing profiles for current Tenant

Retrieve the current Tenant's default VPC routing profile and the profiles it may explicitly select at the specified Site.

+

User must have authorization role with TENANT_ADMIN suffix. Alternative profiles are returned only when TargetedInstanceCreation is effective for this Tenant at the Site.

+
Authorizations:
JWTBearerToken
path Parameters
org
required
string

Name of the Org

+
query Parameters
siteId
required
string <uuid>

ID of the Site where the VPC will be created

+

Responses

Response Schema: application/json
defaultRoutingProfile
required
string

Routing profile applied by Core when a VPC omits routingProfile

+
permittedRoutingProfiles
required
Array of strings

Site-configured routing profiles the Tenant may choose at this Site. Choosing defaultRoutingProfile inherits it by omitting routingProfile; other returned values can be sent explicitly when creating a VPC.

+

Response samples

Content type
application/json
{
  • "defaultRoutingProfile": "internal",
  • "permittedRoutingProfiles": [
    ]
}

Retrieve per-tenant instance type allocation stats for a site

Returns instance type allocation stats grouped by tenant for the specified site.

User must have authorization role with PROVIDER_ADMIN suffix. The specified site must belong to the Provider.

@@ -934,7 +966,7 @@

Typical API Call Flow for Tenant

" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG">

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

Response samples

Content type
application/json
[
  • {
    }
]

Tenant Account

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

Response samples

Content type
application/json
[
  • {
    }
]

Tenant Account

Typical API Call Flow for Tenant " class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv">

Network virtualization type of the VPC. If no value is specified, then defaults to FNN if Site has native networking enabled, or ETHERNET_VIRTUALIZER if native networking is disabled. Flat VPCs hold instances on zero-DPU hosts (or hosts with their DPU in NIC mode) and are never auto-selected -- FLAT must be specified explicitly.

slaacEnabled
boolean
Default: false

When true, Core allocates a /64 to each instance interface that includes IPv6 and retains the prefix without assigning a concrete IPv6 host address. It is supported only for FNN VPCs and fixed during creation. False or omission disables SLAAC. Before persistence, REST requires vpcSlaac in the latest successfully stored configuration inventory for the selected Site. Periodic Site inventory reports whether Core supports this feature, so the stored value can lag a Core rollout. False or missing vpcSlaac returns 412 before REST persistence or workflow dispatch. This flag does not verify DPU agent versions. When a new API server release is deployed, DPU agents roll forward, and instance network configuration may fail transiently until eligible agents converge. NICo does not yet configure router advertisements (RAs); that support is tracked by https://github.com/NVIDIA/infra-controller/issues/2398.

-
routingProfile
string or null [ 3 .. 64 ] characters

Specify routing profile for the VPC. Only supported when networkVirtualizationType is set to FNN, or when networkVirtualizationType is omitted and Site has Native Networking enabled. Requires Tenant to have elevated privilege. Current accepted values are privileged-internal, internal, and external.

+
routingProfile
string or null [ 3 .. 64 ] characters

Specify a Site-configured routing profile returned by GET /tenant/current/routing-profile for the VPC. Only supported when networkVirtualizationType is set to FNN, or when networkVirtualizationType is omitted and Site has Native Networking enabled. Requires Tenant to have elevated privilege.

VpcRoutingProfileOverrides (object) or null

Routing-profile properties to overlay on the resolved named profile. Only supported for FNN VPCs and requires TargetedInstanceCreation to be effective for the Tenant at the VPC's Site. routingProfile may be omitted when the Site and Tenant configuration select a named profile.

One of
Array of objects or null (VpcRouteTarget)
Typical API Call Flow for Tenant