Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 40 additions & 2 deletions crates/api-core/src/handlers/tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) => {
Comment thread
kfelternv marked this conversation as resolved.
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::<Vec<_>>();
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?;
Expand Down
44 changes: 44 additions & 0 deletions crates/api-core/src/tests/tenants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions crates/rpc/proto/forge.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

message TenantKeysetIdentifier {
Expand Down
1 change: 1 addition & 0 deletions crates/rpc/src/model/tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ impl TryFrom<Tenant> for rpc::forge::FindTenantResponse {
fn try_from(value: Tenant) -> Result<Self, Self::Error> {
Ok(rpc::forge::FindTenantResponse {
tenant: Some(value.try_into()?),
permitted_routing_profile_types: vec![],
})
}
}
Expand Down
8 changes: 7 additions & 1 deletion rest-api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
122 changes: 122 additions & 0 deletions rest-api/api/pkg/api/handler/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"net/http"

"github.com/google/uuid"
temporalClient "go.temporal.io/sdk/client"

"github.com/rs/zerolog"
Expand All @@ -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 ~~~~~ //
Expand Down Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions rest-api/api/pkg/api/handler/vpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading