From 2e72ed8aedd9929dda3933848d150aeeafa55b62 Mon Sep 17 00:00:00 2001 From: Kyle Felter Date: Fri, 28 Aug 2026 12:52:16 -0500 Subject: [PATCH 1/7] feat(tui): Allow selecting routing profile during VPC creation Signed-off-by: Kyle Felter --- crates/api-core/src/handlers/tenant.rs | 40 +++- crates/api-core/src/tests/tenants.rs | 19 ++ crates/rpc/proto/forge.proto | 3 + crates/rpc/src/model/tenant.rs | 1 + rest-api/api/pkg/api/handler/tenant.go | 122 ++++++++++++ rest-api/api/pkg/api/model/tenant.go | 32 +++ rest-api/api/pkg/api/model/tenant_test.go | 33 +++ rest-api/api/pkg/api/model/vpc.go | 6 +- rest-api/api/pkg/api/routes.go | 5 + rest-api/api/pkg/api/routes_test.go | 4 +- rest-api/cli/tui/commands.go | 40 +++- .../cli/tui/regression_specialized_test.go | 55 +++++ rest-api/docs/index.html | 42 +++- rest-api/openapi/spec.yaml | 60 ++++++ rest-api/proto/core/gen/v1/nico_nico.pb.go | 23 ++- rest-api/proto/core/src/v1/nico_nico.proto | 3 + rest-api/sdk/standard/api_tenant.go | 160 +++++++++++++++ .../standard/model_tenant_routing_profiles.go | 188 ++++++++++++++++++ 18 files changed, 818 insertions(+), 18 deletions(-) create mode 100644 rest-api/sdk/standard/model_tenant_routing_profiles.go diff --git a/crates/api-core/src/handlers/tenant.rs b/crates/api-core/src/handlers/tenant.rs index d4fd7b18b0..75ccaae78a 100644 --- a/crates/api-core/src/handlers/tenant.rs +++ b/crates/api-core/src/handlers/tenant.rs @@ -133,8 +133,44 @@ 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_vpc_routing_profile_types: vec![], + }, + Some(t) => { + let permitted_vpc_routing_profile_types = match ( + t.routing_profile_type.as_deref(), + api.runtime_config.fnn.as_ref(), + ) { + (Some(tenant_profile_type), Some(fnn)) => { + let tenant_access_tier = fnn + .routing_profiles + .get(tenant_profile_type) + .ok_or_else(|| CarbideError::NotFoundError { + kind: "RoutingProfile", + id: tenant_profile_type.to_string(), + })? + .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 + } + _ => vec![], + }; + let mut response: rpc::FindTenantResponse = + t.try_into().map_err(CarbideError::from)?; + response.permitted_vpc_routing_profile_types = + permitted_vpc_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..3b895bc1e9 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_vpc_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_vpc_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 @@ -470,6 +488,7 @@ async fn test_tenant_create_without_fnn(pool: sqlx::PgPool) { .unwrap() .into_inner(); + assert!(find_tenant.permitted_vpc_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 db5a98a4b1..d4582749d3 100644 --- a/crates/rpc/proto/forge.proto +++ b/crates/rpc/proto/forge.proto @@ -6055,6 +6055,9 @@ message FindTenantRequest { } message FindTenantResponse { Tenant tenant = 1; + // Named VPC routing profiles whose access tier is permitted for this Tenant. + // Empty when FNN or the Tenant routing profile is not configured. + repeated string permitted_vpc_routing_profile_types = 2; } message TenantKeysetIdentifier { diff --git a/crates/rpc/src/model/tenant.rs b/crates/rpc/src/model/tenant.rs index 77ccb6f736..386570940f 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_vpc_routing_profile_types: vec![], }) } } diff --git a/rest-api/api/pkg/api/handler/tenant.go b/rest-api/api/pkg/api/handler/tenant.go index 3314441ea6..d98af81d48 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 Profiles Handler ~~~~~ // + +// GetCurrentTenantRoutingProfilesHandler retrieves the routing profiles the +// current Tenant may use at one Site. +type GetCurrentTenantRoutingProfilesHandler struct { + dbSession *cdb.Session + scp *sc.ClientPool + tracerSpan *cutil.TracerSpan +} + +// NewGetCurrentTenantRoutingProfilesHandler initializes the routing-profile handler. +func NewGetCurrentTenantRoutingProfilesHandler(dbSession *cdb.Session, scp *sc.ClientPool) GetCurrentTenantRoutingProfilesHandler { + return GetCurrentTenantRoutingProfilesHandler{ + 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.APITenantRoutingProfiles +// @Router /v2/org/{org}/nico/tenant/current/routing-profiles [get] +func (gctrph GetCurrentTenantRoutingProfilesHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("TenantRoutingProfiles", "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.APITenantRoutingProfiles{} + 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/model/tenant.go b/rest-api/api/pkg/api/model/tenant.go index e7bccc6f03..698d694c2d 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,37 @@ type APITenant struct { Deprecations []APIDeprecation `json:"deprecations"` } +// APITenantRoutingProfiles describes the routing profiles a Tenant may select +// for VPC creation at one Site. +type APITenantRoutingProfiles struct { + // TenantDefaultRoutingProfile is the profile Core applies when a VPC omits routingProfile. + TenantDefaultRoutingProfile string `json:"tenantDefaultRoutingProfile"` + // PermittedRoutingProfiles contains the profiles this Tenant may select explicitly. + 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 default is exposed as +// selectable. +func (atrp *APITenantRoutingProfiles) FromProto(response *corev1.FindTenantResponse, allowAlternatives bool) { + *atrp = APITenantRoutingProfiles{PermittedRoutingProfiles: []string{}} + if response == nil || response.GetTenant() == nil { + return + } + + atrp.TenantDefaultRoutingProfile = NormalizeAPIVpcRoutingProfileFromSite(response.GetTenant().GetRoutingProfileType()) + if !allowAlternatives { + if atrp.TenantDefaultRoutingProfile != "" { + atrp.PermittedRoutingProfiles = append(atrp.PermittedRoutingProfiles, atrp.TenantDefaultRoutingProfile) + } + return + } + + for _, profile := range response.GetPermittedVpcRoutingProfileTypes() { + 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..675f9c9fd1 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 TestAPITenantRoutingProfilesFromProto(t *testing.T) { + coreResponse := &corev1.FindTenantResponse{ + Tenant: &corev1.Tenant{RoutingProfileType: cutil.GetPtr("INTERNAL")}, + PermittedVpcRoutingProfileTypes: []string{"EXTERNAL", "INTERNAL"}, + } + + t.Run("allows site-scoped alternatives", func(t *testing.T) { + response := &APITenantRoutingProfiles{} + response.FromProto(coreResponse, true) + + assert.Equal(t, "internal", response.TenantDefaultRoutingProfile) + assert.Equal(t, []string{"external", "internal"}, response.PermittedRoutingProfiles) + }) + + t.Run("limits selection to tenant default without privilege", func(t *testing.T) { + response := &APITenantRoutingProfiles{} + response.FromProto(coreResponse, false) + + assert.Equal(t, "internal", response.TenantDefaultRoutingProfile) + assert.Equal(t, []string{"internal"}, response.PermittedRoutingProfiles) + }) + + t.Run("keeps response keys stable for missing tenant", func(t *testing.T) { + response := &APITenantRoutingProfiles{} + response.FromProto(&corev1.FindTenantResponse{}, true) + + body, err := json.Marshal(response) + require.NoError(t, err) + assert.JSONEq(t, `{"tenantDefaultRoutingProfile":"","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..fc67cea00d 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 } @@ -600,7 +602,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/routes.go b/rest-api/api/pkg/api/routes.go index 7bb0144671..cbe1eff26a 100644 --- a/rest-api/api/pkg/api/routes.go +++ b/rest-api/api/pkg/api/routes.go @@ -157,6 +157,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-profiles", + Method: http.MethodGet, + Handler: apiHandler.NewGetCurrentTenantRoutingProfilesHandler(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 f2d0b275d0..ca08c13b42 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-profiles") machineAdminPath := "/org/:orgName/" + cfg.GetAPIName() + "/machine/:id" assertRouteExists(t, got, http.MethodPatch, machineAdminPath+"/bmc/reset") diff --git a/rest-api/cli/tui/commands.go b/rest-api/cli/tui/commands.go index eae37aaa38..c0b225fbd3 100644 --- a/rest-api/cli/tui/commands.go +++ b/rest-api/cli/tui/commands.go @@ -623,7 +623,39 @@ func cmdVPCCreate(s *Session, _ []string) error { if strings.TrimSpace(desc) != "" { body["description"] = desc } - LogCmd(s, "vpc", "create", "--name", name, "--site-id", site.ID) + + routingProfile := "" + siteRaw, _ := site.Raw.(map[string]interface{}) + siteConfig, _ := siteRaw["config"].(map[string]interface{}) + nativeNetworking, _ := siteConfig["nativeNetworking"].(bool) + if nativeNetworking { + profilesResponse, _, requestErr := s.Client.Do("GET", apiPath(s, "tenant/current/routing-profiles"), nil, map[string]string{"siteId": site.ID}, nil) + if requestErr != nil { + return fmt.Errorf("fetching Tenant routing profiles: %w", requestErr) + } + var profiles struct { + TenantDefaultRoutingProfile string `json:"tenantDefaultRoutingProfile"` + PermittedRoutingProfiles []string `json:"permittedRoutingProfiles"` + } + if err := json.Unmarshal(profilesResponse, &profiles); err != nil { + return fmt.Errorf("parsing Tenant routing profiles: %w", err) + } + routingProfile, err = PromptChoice( + fmt.Sprintf("Routing profile (%s (tenant default))", profiles.TenantDefaultRoutingProfile), + profiles.PermittedRoutingProfiles, + profiles.TenantDefaultRoutingProfile, + ) + if err != nil { + return err + } + body["routingProfile"] = routingProfile + } + + logArgs := []string{"vpc", "create", "--name", name, "--site-id", site.ID} + if routingProfile != "" { + logArgs = append(logArgs, "--routing-profile", routingProfile) + } + LogCmd(s, logArgs...) bodyJSON, _ := json.Marshal(body) resp, _, err := s.Client.Do("POST", apiPath(s, "vpc"), nil, nil, bodyJSON) if err != nil { @@ -634,7 +666,11 @@ func cmdVPCCreate(s *Session, _ []string) error { if err := json.Unmarshal(resp, &created); err != nil { return fmt.Errorf("parsing created VPC: %w", err) } - fmt.Printf("%s VPC created: %s (%s)\n", Green("OK"), str(created, "name"), str(created, "id")) + if routingProfile != "" { + fmt.Printf("%s VPC created: %s (%s), routing profile: %s\n", Green("OK"), str(created, "name"), str(created, "id"), routingProfile) + } 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 12de6e6ec0..38c8a3ab01 100644 --- a/rest-api/cli/tui/regression_specialized_test.go +++ b/rest-api/cli/tui/regression_specialized_test.go @@ -497,6 +497,61 @@ func TestCmdInstanceUpdate_SendsAttributeOnlyPatch(t *testing.T) { assert.Contains(t, output, "Instance updated: new-name (instance-1)") } +func TestCmdVPCCreate_SelectsPermittedRoutingProfile(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-profiles": + _, _ = io.WriteString(w, `{"tenantDefaultRoutingProfile":"external","permittedRoutingProfiles":["external","internal"]}`) + case "/v2/org/acme/nico/vpc": + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":"vpc-1","name":"profile-vpc"}`) + 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{}{"config": map[string]interface{}{"nativeNetworking": true}}, + }}) + + output, runErr := runSpecializedCommandWithInput(t, "profile-vpc\n\ninternal\n", 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-profiles", 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, `{"name":"profile-vpc","routingProfile":"internal","siteId":"site-1"}`, got[1].body) + assert.Contains(t, output, "Routing profile (external (tenant default))") + assert.Contains(t, output, "--routing-profile internal") + assert.Contains(t, output, "routing profile: internal") +} + 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 1c3863e7a1..2fa2ad334f 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
tenantDefaultRoutingProfile
required
string

Routing profile applied by Core when a VPC omits routingProfile

+
permittedRoutingProfiles
required
Array of strings

Routing profiles the Tenant may explicitly select at this Site

+

Response samples

Content type
application/json
{
  • "tenantDefaultRoutingProfile": "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