From e38b94a57770cfd69c806344ee900aecd40f045d Mon Sep 17 00:00:00 2001 From: Patrice Breton Date: Wed, 15 Jul 2026 10:41:10 -0700 Subject: [PATCH 01/18] rest-api(api): iPXE template + templated-OS REST API, SDK & OpenAPI Add the REST API layer for the Templated iPXE Operating System variant and the read-only iPXE template endpoints: - iPXE template read/list handlers filtered by per-site availability (IpxeTemplateSiteAssociation), with provider- and tenant-account site authorization and an optional siteId filter. - Operating System handler support for the Templated iPXE type: type derivation from request fields, osScope validation/normalization (raw iPXE -> Global; Templated iPXE requires Global/Limited; Image rejects scope), Global auto-expansion to registered tenant sites, and the Core gRPC proxy push branch (image path preserved). Artifact authTokens are redacted in API responses. - Instance / instance-batch wiring for the Templated iPXE OS type. - Route registration, API models, OpenAPI spec (new endpoints, schemas and examples) and regenerated Go SDK. The iPXE template API/SDK/OpenAPI field is named `visibility` (Internal/Public) to match the DB column and the nico-core proto and to avoid confusion with the Operating System `scope` concept. --- rest-api/api/pkg/api/handler/instance.go | 22 + rest-api/api/pkg/api/handler/instancebatch.go | 11 + rest-api/api/pkg/api/handler/ipxetemplate.go | 367 +++++++++++++++ .../api/pkg/api/handler/ipxetemplate_test.go | 323 ++++++++++++++ .../api/pkg/api/handler/operatingsystem.go | 385 +++++++++++++--- .../operatingsystem_templated_proxy_test.go | 179 ++++++++ .../pkg/api/handler/util/common/testing.go | 6 + rest-api/api/pkg/api/model/ipxetemplate.go | 69 +++ rest-api/api/pkg/api/model/operatingsystem.go | 337 +++++++++++++- .../model/operatingsystem_templated_test.go | 215 +++++++++ rest-api/api/pkg/api/routes.go | 11 + rest-api/api/pkg/api/routes_test.go | 1 + rest-api/openapi/spec.yaml | 260 ++++++++++- rest-api/sdk/standard/api_ipxe_template.go | 312 +++++++++++++ rest-api/sdk/standard/client.go | 3 + rest-api/sdk/standard/model_ipxe_template.go | 420 ++++++++++++++++++ .../sdk/standard/model_operating_system.go | 170 +++++++ .../model_operating_system_create_request.go | 170 +++++++ .../model_operating_system_ipxe_artifact.go | 343 ++++++++++++++ .../model_operating_system_ipxe_parameter.go | 162 +++++++ .../model_operating_system_update_request.go | 170 +++++++ 21 files changed, 3875 insertions(+), 61 deletions(-) create mode 100644 rest-api/api/pkg/api/handler/ipxetemplate.go create mode 100644 rest-api/api/pkg/api/handler/ipxetemplate_test.go create mode 100644 rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go create mode 100644 rest-api/api/pkg/api/model/ipxetemplate.go create mode 100644 rest-api/api/pkg/api/model/operatingsystem_templated_test.go create mode 100644 rest-api/sdk/standard/api_ipxe_template.go create mode 100644 rest-api/sdk/standard/model_ipxe_template.go create mode 100644 rest-api/sdk/standard/model_operating_system_ipxe_artifact.go create mode 100644 rest-api/sdk/standard/model_operating_system_ipxe_parameter.go diff --git a/rest-api/api/pkg/api/handler/instance.go b/rest-api/api/pkg/api/handler/instance.go index 55cfc96a8b..8ca5049637 100644 --- a/rest-api/api/pkg/api/handler/instance.go +++ b/rest-api/api/pkg/api/handler/instance.go @@ -213,6 +213,17 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte }, UserData: apiRequest.UserData, }, osID, nil + } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + return &corev1.InstanceOperatingSystemConfig{ + RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + Variant: &corev1.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &corev1.OperatingSystemId{ + Value: os.ID.String(), + }, + }, + UserData: apiRequest.UserData, + }, osID, nil } else { return &corev1.InstanceOperatingSystemConfig{ PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, @@ -2098,6 +2109,17 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte }, UserData: userData, }, osID, nil + } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + return &corev1.InstanceOperatingSystemConfig{ + RunProvisioningInstructionsOnEveryBoot: alwaysBootWithCustomIpxe, + PhoneHomeEnabled: phoneHomeEnabled, + Variant: &corev1.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &corev1.OperatingSystemId{ + Value: os.ID.String(), + }, + }, + UserData: userData, + }, osID, nil } else if os.Type == cdbm.OperatingSystemTypeImage { return &corev1.InstanceOperatingSystemConfig{ PhoneHomeEnabled: phoneHomeEnabled, diff --git a/rest-api/api/pkg/api/handler/instancebatch.go b/rest-api/api/pkg/api/handler/instancebatch.go index 6b693b26b5..91fac19a3a 100644 --- a/rest-api/api/pkg/api/handler/instancebatch.go +++ b/rest-api/api/pkg/api/handler/instancebatch.go @@ -186,6 +186,17 @@ func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c }, UserData: apiRequest.UserData, }, osID, nil + } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + return &corev1.InstanceOperatingSystemConfig{ + RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + Variant: &corev1.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &corev1.OperatingSystemId{ + Value: os.ID.String(), + }, + }, + UserData: apiRequest.UserData, + }, osID, nil } else { return &corev1.InstanceOperatingSystemConfig{ PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, diff --git a/rest-api/api/pkg/api/handler/ipxetemplate.go b/rest-api/api/pkg/api/handler/ipxetemplate.go new file mode 100644 index 0000000000..a076a53435 --- /dev/null +++ b/rest-api/api/pkg/api/handler/ipxetemplate.go @@ -0,0 +1,367 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "encoding/json" + "errors" + "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" + tclient "go.temporal.io/sdk/client" + + "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" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/pagination" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cdbp "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" +) + +// ~~~~~ GetAll Handler ~~~~~ // + +// GetAllIpxeTemplateHandler is the API Handler for getting all iPXE templates +type GetAllIpxeTemplateHandler struct { + dbSession *cdb.Session + tc tclient.Client + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewGetAllIpxeTemplateHandler initializes and returns a new handler for getting all iPXE templates +func NewGetAllIpxeTemplateHandler(dbSession *cdb.Session, tc tclient.Client, cfg *config.Config) GetAllIpxeTemplateHandler { + return GetAllIpxeTemplateHandler{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Get all iPXE templates +// @Description Get all iPXE templates propagated from nico-core. Templates are global (one row per stable core template UUID); per-site availability is recorded internally. The `siteId` query parameter is optional and may be repeated to restrict results to templates available at one or more sites. When omitted, a Provider Admin/Viewer receives templates available at any site owned by their infrastructure provider; a Tenant Admin receives templates available at any site whose provider the tenant has a Tenant Account on. +// @Tags iPXE Template +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param siteId query []string false "Optional site ID(s); may be repeated to restrict results to templates available at any of the sites" +// @Param pageNumber query integer false "Page number of results returned" +// @Param pageSize query integer false "Number of results per page" +// @Param orderBy query string false "Order by field" +// @Success 200 {object} []model.APIIpxeTemplate +// @Router /v2/org/{org}/nico/ipxe-template [get] +func (h GetAllIpxeTemplateHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("IpxeTemplate", "GetAll", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate role (Provider Admin/Viewer or Tenant Admin) and org membership + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, h.dbSession, org, dbUser, true, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) + } + + // Parse optional siteId query parameters. Multiple values (repeated + // `?siteId=...&siteId=...`) are supported. + requestedSiteIDStrs := c.QueryParams()["siteId"] + requestedSiteIDs := make([]uuid.UUID, 0, len(requestedSiteIDStrs)) + for _, s := range requestedSiteIDStrs { + if s == "" { + continue + } + parsed, perr := uuid.Parse(s) + if perr != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid siteId in query parameter: %s", s), nil) + } + requestedSiteIDs = append(requestedSiteIDs, parsed) + } + + // Build the caller's authorized site set, tracking which sites come from the + // provider path vs the tenant path. A site can be in both sets for a + // dual-role caller — provider access wins (fewer restrictions). + providerSites := mapset.NewSet[uuid.UUID]() + tenantSites := mapset.NewSet[uuid.UUID]() + + if infrastructureProvider != nil { + siteDAO := cdbm.NewSiteDAO(h.dbSession) + sites, _, serr := siteDAO.GetAll(ctx, nil, + cdbm.SiteFilterInput{InfrastructureProviderIDs: []uuid.UUID{infrastructureProvider.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve provider sites, DB error", nil) + } + for i := range sites { + providerSites.Add(sites[i].ID) + } + } + + if tenant != nil { + tsDAO := cdbm.NewTenantSiteDAO(h.dbSession) + tss, _, terr := tsDAO.GetAll(ctx, nil, + cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenant.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Site associations, DB error", nil) + } + for i := range tss { + tenantSites.Add(tss[i].SiteID) + } + } + + isAuthorized := func(id uuid.UUID) bool { + return providerSites.Contains(id) || tenantSites.Contains(id) + } + + // Determine the effective site filter: + // - siteId(s) provided: must all be authorized; use them as-is. + // - siteId(s) omitted: use the union of provider and tenant accessible sites. + var effectiveSiteIDs []uuid.UUID + if len(requestedSiteIDs) > 0 { + for _, id := range requestedSiteIDs { + if !isAuthorized(id) { + logger.Warn().Str("siteID", id.String()).Msg("org not authorized to access requested Site") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Current org is not authorized to access Site: %s", id.String()), nil) + } + } + effectiveSiteIDs = requestedSiteIDs + } else { + effectiveSiteIDs = providerSites.Union(tenantSites).ToSlice() + } + + // No authorized sites — neither provider-owned nor reachable via a tenant account. + if len(effectiveSiteIDs) == 0 { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not associated with any Site", nil) + } + + // Validate pagination request + pageRequest := pagination.PageRequest{} + if err := c.Bind(&pageRequest); err != nil { + logger.Warn().Err(err).Msg("error binding pagination request data into API model") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request pagination data", nil) + } + if err := pageRequest.Validate(cdbm.IpxeTemplateOrderByFields); err != nil { + logger.Warn().Err(err).Msg("error validating pagination request data") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to validate pagination request data", err) + } + + // Resolve which template IDs are available at the authorized sites via + // the IpxeTemplateSiteAssociation table. + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(h.dbSession) + associations, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{SiteIDs: effectiveSiteIDs}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE template site associations, DB error", nil) + } + + templateIDSet := mapset.NewSet[uuid.UUID]() + for _, a := range associations { + templateIDSet.Add(a.IpxeTemplateID) + } + templateIDs := templateIDSet.ToSlice() + + templateDAO := cdbm.NewIpxeTemplateDAO(h.dbSession) + templates, total, err := templateDAO.GetAll( + ctx, + nil, + cdbm.IpxeTemplateFilterInput{IpxeTemplateIDs: templateIDs}, + cdbp.PageInput{ + Offset: pageRequest.Offset, + Limit: pageRequest.Limit, + OrderBy: pageRequest.OrderBy, + }, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE templates from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE templates, DB error", nil) + } + + apiTemplates := []*model.APIIpxeTemplate{} + for i := range templates { + apiTemplates = append(apiTemplates, model.NewAPIIpxeTemplate(&templates[i])) + } + + pageResponse := pagination.NewPageResponse(*pageRequest.PageNumber, *pageRequest.PageSize, total, pageRequest.OrderByStr) + pageHeader, err := json.Marshal(pageResponse) + if err != nil { + logger.Error().Err(err).Msg("error marshaling pagination response") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to generate pagination response header", nil) + } + c.Response().Header().Set(pagination.ResponseHeaderName, string(pageHeader)) + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, apiTemplates) +} + +// ~~~~~ Get Handler ~~~~~ // + +// GetIpxeTemplateHandler is the API Handler for retrieving a single iPXE template +type GetIpxeTemplateHandler struct { + dbSession *cdb.Session + tc tclient.Client + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewGetIpxeTemplateHandler initializes and returns a new handler to retrieve an iPXE template +func NewGetIpxeTemplateHandler(dbSession *cdb.Session, tc tclient.Client, cfg *config.Config) GetIpxeTemplateHandler { + return GetIpxeTemplateHandler{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Retrieve an iPXE template +// @Description Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is currently available (Provider Admin/Viewer for a Site owned by their infrastructure provider, or Tenant Admin with a Tenant Account on a Site reporting the template). +// @Tags iPXE Template +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param id path string true "Stable template ID (UUID from core)" +// @Success 200 {object} model.APIIpxeTemplate +// @Router /v2/org/{org}/nico/ipxe-template/{id} [get] +func (h GetIpxeTemplateHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("IpxeTemplate", "Get", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate role (Provider Admin/Viewer or Tenant Admin) — this also validates + // org membership, so no separate membership check is needed here. + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, h.dbSession, org, dbUser, true, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) + } + + // Parse template ID from URL (this is the stable core template UUID, which is + // also the primary key in REST). + templateIDStr := c.Param("id") + templateID, err := uuid.Parse(templateIDStr) + if err != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid iPXE template ID: %s", templateIDStr), nil) + } + + logger = logger.With().Str("IpxeTemplate ID", templateIDStr).Logger() + h.tracerSpan.SetAttribute(handlerSpan, attribute.String("ipxe_template_id", templateIDStr), logger) + + templateDAO := cdbm.NewIpxeTemplateDAO(h.dbSession) + tmpl, err := templateDAO.Get(ctx, nil, templateID) + if err != nil { + if errors.Is(err, cdb.ErrDoesNotExist) { + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, fmt.Sprintf("Could not find iPXE template with ID: %s", templateIDStr), nil) + } + logger.Error().Err(err).Msg("error retrieving iPXE template from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE template, DB error", nil) + } + + // Authorization: caller must be associated (via provider ownership or tenant + // account) with at least one Site at which this template is currently + // reported. + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(h.dbSession) + associations, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{IpxeTemplateIDs: []uuid.UUID{templateID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify iPXE template authorization, DB error", nil) + } + + if !callerHasAccessToAnyAssociatedSite(ctx, logger, h.dbSession, infrastructureProvider, tenant, associations) { + logger.Warn().Msg("caller is not authorized to access any Site associated with this iPXE template") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not authorized to access this iPXE template", nil) + } + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, model.NewAPIIpxeTemplate(tmpl)) +} + +// callerHasAccessToAnyAssociatedSite returns true when the caller (provider or tenant) +// has access to at least one site in the given association set. +func callerHasAccessToAnyAssociatedSite( + ctx context.Context, + logger zerolog.Logger, + dbSession *cdb.Session, + provider *cdbm.InfrastructureProvider, + tenant *cdbm.Tenant, + associations []cdbm.IpxeTemplateSiteAssociation, +) bool { + if len(associations) == 0 { + return false + } + + siteIDs := make([]uuid.UUID, 0, len(associations)) + for _, a := range associations { + siteIDs = append(siteIDs, a.SiteID) + } + + // Provider path: any site owned by the caller's provider. + if provider != nil { + siteDAO := cdbm.NewSiteDAO(dbSession) + sites, _, serr := siteDAO.GetAll(ctx, nil, cdbm.SiteFilterInput{ + InfrastructureProviderIDs: []uuid.UUID{provider.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites for iPXE template authorization") + return false + } + if len(sites) > 0 { + return true + } + } + + // Tenant path: any site reachable via a TenantSite association. + if tenant != nil { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, terr := tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations for iPXE template authorization") + return false + } + if len(tss) > 0 { + return true + } + } + + return false +} diff --git a/rest-api/api/pkg/api/handler/ipxetemplate_test.go b/rest-api/api/pkg/api/handler/ipxetemplate_test.go new file mode 100644 index 0000000000..f8bd5fa0da --- /dev/null +++ b/rest-api/api/pkg/api/handler/ipxetemplate_test.go @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + authz "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization" + "github.com/NVIDIA/infra-controller/rest-api/common/pkg/otelecho" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tmocks "go.temporal.io/sdk/mocks" +) + +// ipxeTemplateTestFixture wires up a provider, one of its sites, a tenant with +// access to that site, and two templates: one available at the site (via an +// IpxeTemplateSiteAssociation) and one that exists but is not available anywhere. +type ipxeTemplateTestFixture struct { + dbSession *cdb.Session + ipOrg string + tnOrg string + emptyIPOrg string + provider *cdbm.InfrastructureProvider + site *cdbm.Site + tenant *cdbm.Tenant + availTmpl *cdbm.IpxeTemplate + orphanTmpl *cdbm.IpxeTemplate + provUser *cdbm.User + tnUser *cdbm.User +} + +func buildIpxeTemplateFixture(t *testing.T) (*ipxeTemplateTestFixture, func()) { + ctx := context.Background() + dbSession := testMachineInitDB(t) + common.TestSetupSchema(t, dbSession) + + f := &ipxeTemplateTestFixture{ + ipOrg: "ipxet-ip-org", + tnOrg: "ipxet-tn-org", + emptyIPOrg: "ipxet-empty-ip-org", + } + + // A provider admin whose org owns the site, plus an empty provider org to + // exercise the "no associated site" path. + f.provUser = testMachineBuildUser(t, dbSession, uuid.NewString(), []string{f.ipOrg, f.emptyIPOrg}, []string{authz.ProviderAdminRole}) + f.provider = testMachineBuildInfrastructureProvider(t, dbSession, f.ipOrg, "ipxet-provider") + testMachineBuildInfrastructureProvider(t, dbSession, f.emptyIPOrg, "ipxet-empty-provider") + f.site = testMachineBuildSite(t, dbSession, f.provider, "ipxet-site", cdbm.SiteStatusRegistered) + + // A tenant admin with a tenant account on the provider's site. + f.tnUser = testMachineBuildUser(t, dbSession, uuid.NewString(), []string{f.tnOrg}, []string{authz.TenantAdminRole}) + f.tenant = testMachineBuildTenant(t, dbSession, f.tnOrg, "ipxet-tenant") + testBuildTenantSiteAssociation(t, dbSession, f.tnOrg, f.tenant.ID, f.site.ID, f.tnUser.ID) + + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + availTmpl, err := templateDAO.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "ipxet-available", + Template: "#!ipxe\n", + Visibility: "Public", + }) + require.NoError(t, err) + f.availTmpl = availTmpl + + orphanTmpl, err := templateDAO.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "ipxet-orphan", + Template: "#!ipxe\n", + Visibility: "Public", + }) + require.NoError(t, err) + f.orphanTmpl = orphanTmpl + + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + _, err = itsaDAO.Create(ctx, nil, cdbm.IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: availTmpl.ID, SiteID: f.site.ID}) + require.NoError(t, err) + + cleanup := func() { dbSession.Close() } + // Store the session on the fixture indirectly via closures used by the tests. + f.dbSession = dbSession + return f, cleanup +} + +func TestIpxeTemplateHandler_GetAll(t *testing.T) { + f, cleanup := buildIpxeTemplateFixture(t) + defer cleanup() + + ctx := context.Background() + cfg := common.GetTestConfig() + tc := &tmocks.Client{} + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + reqOrgName string + user *cdbm.User + siteID *uuid.UUID + expectedErr bool + expectedStatus int + expectedNames []string + }{ + { + name: "error when user not in request context", + reqOrgName: f.ipOrg, + user: nil, + expectedErr: true, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "error when user not a member of org", + reqOrgName: "SomeOtherOrg", + user: f.provUser, + expectedErr: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "provider admin sees templates available at owned sites", + reqOrgName: f.ipOrg, + user: f.provUser, + expectedErr: false, + expectedStatus: http.StatusOK, + expectedNames: []string{f.availTmpl.Name}, + }, + { + name: "tenant admin sees templates available at accessible sites", + reqOrgName: f.tnOrg, + user: f.tnUser, + expectedErr: false, + expectedStatus: http.StatusOK, + expectedNames: []string{f.availTmpl.Name}, + }, + { + name: "provider admin with explicit authorized siteId", + reqOrgName: f.ipOrg, + user: f.provUser, + siteID: &f.site.ID, + expectedErr: false, + expectedStatus: http.StatusOK, + expectedNames: []string{f.availTmpl.Name}, + }, + { + name: "provider admin with unauthorized siteId is forbidden", + reqOrgName: f.ipOrg, + user: f.provUser, + siteID: cutilPtrUUID(uuid.New()), + expectedErr: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "provider org without any site returns forbidden", + reqOrgName: f.emptyIPOrg, + user: f.provUser, + expectedErr: true, + expectedStatus: http.StatusForbidden, + }, + } + + for _, tc2 := range tests { + t.Run(tc2.name, func(t *testing.T) { + e := echo.New() + q := url.Values{} + if tc2.siteID != nil { + q.Add("siteId", tc2.siteID.String()) + } + path := fmt.Sprintf("/v2/org/%s/nico/ipxe-template?%s", tc2.reqOrgName, q.Encode()) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + ec := e.NewContext(req, rec) + ec.SetParamNames("orgName") + ec.SetParamValues(tc2.reqOrgName) + if tc2.user != nil { + ec.Set("user", tc2.user) + } + ctx = context.WithValue(ctx, otelecho.TracerKey, tracer) + ec.SetRequest(ec.Request().WithContext(ctx)) + + h := NewGetAllIpxeTemplateHandler(f.dbSession, tc, cfg) + err := h.Handle(ec) + assert.Nil(t, err) + require.Equal(t, tc2.expectedStatus, rec.Code) + assert.Equal(t, tc2.expectedErr, rec.Code != http.StatusOK) + + if !tc2.expectedErr { + rsp := []model.APIIpxeTemplate{} + err := json.Unmarshal(rec.Body.Bytes(), &rsp) + assert.Nil(t, err) + gotNames := map[string]bool{} + for _, tmpl := range rsp { + gotNames[tmpl.Name] = true + } + for _, want := range tc2.expectedNames { + assert.True(t, gotNames[want], "expected template %q in response", want) + } + // The orphan template must never be returned. + assert.False(t, gotNames[f.orphanTmpl.Name], "orphan template must not be visible") + } + }) + } +} + +func TestIpxeTemplateHandler_Get(t *testing.T) { + f, cleanup := buildIpxeTemplateFixture(t) + defer cleanup() + + ctx := context.Background() + cfg := common.GetTestConfig() + tc := &tmocks.Client{} + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx) + + tests := []struct { + name string + reqOrgName string + user *cdbm.User + templateID string + expectedErr bool + expectedStatus int + }{ + { + name: "error when user not in request context", + reqOrgName: f.ipOrg, + user: nil, + templateID: f.availTmpl.ID.String(), + expectedErr: true, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "error when user not a member of org", + reqOrgName: "SomeOtherOrg", + user: f.provUser, + templateID: f.availTmpl.ID.String(), + expectedErr: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "error when template id is not a uuid", + reqOrgName: f.ipOrg, + user: f.provUser, + templateID: "not-a-uuid", + expectedErr: true, + expectedStatus: http.StatusBadRequest, + }, + { + name: "error when template does not exist", + reqOrgName: f.ipOrg, + user: f.provUser, + templateID: uuid.New().String(), + expectedErr: true, + expectedStatus: http.StatusNotFound, + }, + { + name: "forbidden when template not available at any accessible site", + reqOrgName: f.ipOrg, + user: f.provUser, + templateID: f.orphanTmpl.ID.String(), + expectedErr: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "provider admin can retrieve available template", + reqOrgName: f.ipOrg, + user: f.provUser, + templateID: f.availTmpl.ID.String(), + expectedErr: false, + expectedStatus: http.StatusOK, + }, + { + name: "tenant admin can retrieve available template", + reqOrgName: f.tnOrg, + user: f.tnUser, + templateID: f.availTmpl.ID.String(), + expectedErr: false, + expectedStatus: http.StatusOK, + }, + } + + for _, tc2 := range tests { + t.Run(tc2.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + ec := e.NewContext(req, rec) + ec.SetParamNames("orgName", "id") + ec.SetParamValues(tc2.reqOrgName, tc2.templateID) + if tc2.user != nil { + ec.Set("user", tc2.user) + } + ctx = context.WithValue(ctx, otelecho.TracerKey, tracer) + ec.SetRequest(ec.Request().WithContext(ctx)) + + h := NewGetIpxeTemplateHandler(f.dbSession, tc, cfg) + err := h.Handle(ec) + assert.Nil(t, err) + require.Equal(t, tc2.expectedStatus, rec.Code) + assert.Equal(t, tc2.expectedErr, rec.Code != http.StatusOK) + + if !tc2.expectedErr { + rsp := &model.APIIpxeTemplate{} + err := json.Unmarshal(rec.Body.Bytes(), rsp) + assert.Nil(t, err) + assert.Equal(t, tc2.templateID, rsp.ID) + } + }) + } +} + +func cutilPtrUUID(id uuid.UUID) *uuid.UUID { return &id } diff --git a/rest-api/api/pkg/api/handler/operatingsystem.go b/rest-api/api/pkg/api/handler/operatingsystem.go index 8a4f5bdf99..f1d295abe5 100644 --- a/rest-api/api/pkg/api/handler/operatingsystem.go +++ b/rest-api/api/pkg/api/handler/operatingsystem.go @@ -14,10 +14,12 @@ import ( "go.opentelemetry.io/otel/attribute" temporalClient "go.temporal.io/sdk/client" tp "go.temporal.io/sdk/temporal" + "google.golang.org/protobuf/proto" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/google/uuid" "github.com/labstack/echo/v4" + "github.com/rs/zerolog" "github.com/NVIDIA/infra-controller/rest-api/api/internal/config" "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" @@ -34,6 +36,124 @@ import ( "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/queue" ) +// NICo Core (forge.Forge) Operating System methods proxied for iPXE / Templated +// iPXE Operating Systems via the generic Core gRPC proxy (epic #1927). Image-type +// Operating Systems continue to use the dedicated OsImage site workflows. +const ( + createOperatingSystemMethod = "/forge.Forge/CreateOperatingSystem" + updateOperatingSystemMethod = "/forge.Forge/UpdateOperatingSystem" + deleteOperatingSystemMethod = "/forge.Forge/DeleteOperatingSystem" +) + +// syncOperatingSystemToSitesViaProxy pushes an iPXE / Templated iPXE Operating +// System create or update to each associated site through the generic NICo Core +// gRPC proxy, updating each site association's status (Synced on success, Error +// on failure). The same request proto is sent to every site (the OS definition is +// site-independent). It returns the number of sites that failed to sync. +func syncOperatingSystemToSitesViaProxy( + ctx context.Context, + logger zerolog.Logger, + dbSession *cdb.Session, + scp *sc.ClientPool, + ossas []cdbm.OperatingSystemSiteAssociation, + fullMethod string, + req proto.Message, +) int { + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + siteErrors := 0 + for _, ossa := range ossas { + slogger := logger.With().Str("Site ID", ossa.SiteID.String()).Logger() + + stc, cerr := scp.GetClientByID(ossa.SiteID) + if cerr != nil { + slogger.Error().Err(cerr).Msg("failed to retrieve Temporal client for Site") + updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to connect to site") + siteErrors++ + continue + } + + // The site ID is the shared key used to encrypt any redacted secret fields + // for transport; no top-level secret fields are redacted here (artifact + // authTokens are nested and carried as-is). + perr := common.ExecuteCoreGRPC(ctx, stc, fullMethod, req, nil, ossa.SiteID.String()) + if perr != nil { + slogger.Error().Err(perr).Int("code", perr.Code).Msg("failed to sync Operating System to site via Core proxy") + updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to sync Operating System to site") + siteErrors++ + continue + } + + updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusSynced, "Operating System successfully synced to site") + } + return siteErrors +} + +// updateOSSAStatusViaProxy updates an Operating System Site Association status and +// records a status detail entry. Failures are logged but not propagated, mirroring +// the best-effort status bookkeeping of the existing image sync paths. +func updateOSSAStatusViaProxy(ctx context.Context, logger zerolog.Logger, ossaDAO cdbm.OperatingSystemSiteAssociationDAO, sdDAO cdbm.StatusDetailDAO, ossaID uuid.UUID, status string, message string) { + if _, err := ossaDAO.Update(ctx, nil, cdbm.OperatingSystemSiteAssociationUpdateInput{ + OperatingSystemSiteAssociationID: ossaID, + Status: cutil.GetPtr(status), + }); err != nil { + logger.Error().Err(err).Str("Status", status).Msg("failed to update Operating System Site Association status") + return + } + if _, err := sdDAO.Create(ctx, nil, cdbm.StatusDetailCreateInput{EntityID: ossaID.String(), Status: status, Message: &message}); err != nil { + logger.Error().Err(err).Msg("failed to create status detail for Operating System Site Association") + } +} + +// updateOperatingSystemAggregateStatus sets the Operating System's aggregate status +// after a proxy sync attempt: Ready when all sites synced, Error when one or more failed. +func updateOperatingSystemAggregateStatus(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, osID uuid.UUID, hadErrors bool) { + osDAO := cdbm.NewOperatingSystemDAO(dbSession) + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + + status := cdbm.OperatingSystemStatusReady + message := "Operating System successfully synced to all sites" + if hadErrors { + status = cdbm.OperatingSystemStatusError + message = "failed to sync Operating System to one or more sites" + } + + if _, err := osDAO.Update(ctx, nil, cdbm.OperatingSystemUpdateInput{OperatingSystemId: osID, Status: &status}); err != nil { + logger.Error().Err(err).Msg("failed to update aggregate Operating System status") + return + } + if _, err := sdDAO.Create(ctx, nil, cdbm.StatusDetailCreateInput{EntityID: osID.String(), Status: status, Message: &message}); err != nil { + logger.Error().Err(err).Msg("failed to create status detail for aggregate Operating System status") + } +} + +// getRegisteredTenantSites returns all Registered sites the tenant has access to. +// Used to resolve target sites for Global-scope Templated iPXE Operating Systems. +func getRegisteredTenantSites(ctx context.Context, dbSession *cdb.Session, tenantID uuid.UUID) ([]cdbm.Site, error) { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, err := tsDAO.GetAll(ctx, nil, + cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenantID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if err != nil { + return nil, err + } + if len(tss) == 0 { + return nil, nil + } + siteIDs := make([]uuid.UUID, len(tss)) + for i, ts := range tss { + siteIDs[i] = ts.SiteID + } + stDAO := cdbm.NewSiteDAO(dbSession) + sites, _, err := stDAO.GetAll(ctx, nil, + cdbm.SiteFilterInput{SiteIDs: siteIDs, Statuses: []string{cdbm.SiteStatusRegistered}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if err != nil { + return nil, err + } + return sites, nil +} + // ~~~~~ Create Handler ~~~~~ // // CreateOperatingSystemHandler is the API Handler for creating new OperatingSystem @@ -159,10 +279,18 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { }) } - // check OS type from request - osType := cdbm.OperatingSystemTypeImage - if apiRequest.IpxeScript != nil { - osType = cdbm.OperatingSystemTypeIPXE + // Infer OS type from the provided source fields (ipxeScript -> iPXE, + // ipxeTemplateId -> Templated iPXE, otherwise Image). + osType := apiRequest.GetOperatingSystemType() + + // Determine the effective scope to persist. Raw iPXE Operating Systems are + // normalized to Global to preserve legacy behavior (a tenant can use them on + // any Instance); validation has already ensured any provided scope is Global + // or unset for raw iPXE. Templated iPXE scope is caller-set and validated; + // Image OS carry a nil scope. + osScope := apiRequest.Scope + if osType == cdbm.OperatingSystemTypeIPXE { + osScope = cutil.GetPtr(cdbm.OperatingSystemScopeGlobal) } // Set the phoneHomeEnabled if provided in request @@ -232,10 +360,23 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { rdbst = append(rdbst, *site) } - // Create status based on OS type + // Global-scope Templated iPXE Operating Systems are synced to every Registered + // site the tenant has access to (siteIds are not specified for Global scope). + if osType == cdbm.OperatingSystemTypeTemplatedIPXE && apiRequest.Scope != nil && *apiRequest.Scope == cdbm.OperatingSystemScopeGlobal { + globalSites, gerr := getRegisteredTenantSites(ctx, csh.dbSession, tenant.ID) + if gerr != nil { + logger.Error().Err(gerr).Msg("error retrieving tenant sites for global-scope Operating System") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant sites, DB error", nil) + } + rdbst = append(rdbst, globalSites...) + } + + // Create status based on OS type. iPXE / Templated iPXE definitions are pushed + // to sites after commit, so Templated iPXE starts in Syncing; raw iPXE (no site + // associations) is immediately Ready. osStatus := cdbm.OperatingSystemStatusReady osStatusMessage := "Operating System is ready for use" - if osType == cdbm.OperatingSystemTypeImage { + if osType == cdbm.OperatingSystemTypeImage || osType == cdbm.OperatingSystemTypeTemplatedIPXE { osStatus = cdbm.OperatingSystemStatusSyncing osStatusMessage = "received Operating System creation request, syncing" } @@ -251,25 +392,29 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { err = cdb.WithTx(ctx, csh.dbSession, func(tx *cdb.Tx) error { // Create the db record for Operating System osInput := cdbm.OperatingSystemCreateInput{ - Name: apiRequest.Name, - Description: apiRequest.Description, - Org: org, - TenantID: &tenant.ID, - OsType: osType, - ImageURL: apiRequest.ImageURL, - ImageSHA: apiRequest.ImageSHA, - ImageAuthType: apiRequest.ImageAuthType, - ImageAuthToken: apiRequest.ImageAuthToken, - ImageDisk: apiRequest.ImageDisk, - RootFsId: apiRequest.RootFsID, - RootFsLabel: apiRequest.RootFsLabel, - IpxeScript: apiRequest.IpxeScript, - UserData: apiRequest.UserData, - AllowOverride: apiRequest.AllowOverride, - EnableBlockStorage: apiRequest.EnableBlockStorage, - PhoneHomeEnabled: phoneHomeEnabled, - Status: osStatus, - CreatedBy: dbUser.ID, + Name: apiRequest.Name, + Description: apiRequest.Description, + Org: org, + TenantID: &tenant.ID, + OsType: osType, + ImageURL: apiRequest.ImageURL, + ImageSHA: apiRequest.ImageSHA, + ImageAuthType: apiRequest.ImageAuthType, + ImageAuthToken: apiRequest.ImageAuthToken, + ImageDisk: apiRequest.ImageDisk, + RootFsId: apiRequest.RootFsID, + RootFsLabel: apiRequest.RootFsLabel, + IpxeScript: apiRequest.IpxeScript, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + IpxeOsScope: osScope, + UserData: apiRequest.UserData, + AllowOverride: apiRequest.AllowOverride, + EnableBlockStorage: apiRequest.EnableBlockStorage, + PhoneHomeEnabled: phoneHomeEnabled, + Status: osStatus, + CreatedBy: dbUser.ID, } createdOs, derr := osDAO.Create(ctx, tx, osInput) if derr != nil { @@ -344,8 +489,13 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { } dbossa = retossa - // Trigger workflows to sync Image based Operating System with various Sites + // Trigger workflows to sync Image based Operating System with various Sites. + // iPXE / Templated iPXE definitions are pushed to sites via the Core gRPC + // proxy after the transaction commits (see below), so they are skipped here. for _, ossa := range dbossa { + if os.Type != cdbm.OperatingSystemTypeImage { + continue + } // Iteration body wrapped in a function literal so `defer cancel()` // scopes to the iteration; otherwise the deferred cancels would // pile up until the WithTx closure returns. @@ -421,12 +571,57 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { return timeoutResp() } + // Push iPXE / Templated iPXE Operating Systems to associated sites through the + // generic Core gRPC proxy (Image OSes are synced in-transaction above). Per-site + // failures are recorded on the association status and do not fail the request. + if cdbm.IsIPXEType(os.Type) && len(dbossa) > 0 { + req := model.BuildCreateOperatingSystemRequest(os) + siteErrors := syncOperatingSystemToSitesViaProxy(ctx, logger, csh.dbSession, csh.scp, dbossa, createOperatingSystemMethod, req) + updateOperatingSystemAggregateStatus(ctx, logger, csh.dbSession, os.ID, siteErrors > 0) + os, dbossd, dbossa = reloadOperatingSystemForResponse(ctx, logger, csh.dbSession, os) + } + // create response apiOperatingSystem := model.NewAPIOperatingSystem(os, dbossd, dbossa, sttsmap) logger.Info().Msg("finishing API handler") return c.JSON(http.StatusCreated, apiOperatingSystem) } +// reloadOperatingSystemForResponse re-reads the Operating System, its recent status +// details, and its site associations after a proxy sync so the API response reflects +// the post-sync state. Best-effort: on any read error the prior values are kept. +func reloadOperatingSystemForResponse(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, os *cdbm.OperatingSystem) (*cdbm.OperatingSystem, []cdbm.StatusDetail, []cdbm.OperatingSystemSiteAssociation) { + osDAO := cdbm.NewOperatingSystemDAO(dbSession) + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + + reloadedOS := os + if v, err := osDAO.GetByID(ctx, nil, os.ID, nil); err == nil { + reloadedOS = v + } else { + logger.Warn().Err(err).Msg("failed to reload Operating System for response") + } + + var ssds []cdbm.StatusDetail + if v, err := sdDAO.GetRecentByEntityIDs(ctx, nil, []string{os.ID.String()}, common.RECENT_STATUS_DETAIL_COUNT); err == nil { + ssds = v + } else { + logger.Warn().Err(err).Msg("failed to reload Operating System status details for response") + } + + var ossas []cdbm.OperatingSystemSiteAssociation + if v, _, err := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + []string{cdbm.SiteRelationName}); err == nil { + ossas = v + } else { + logger.Warn().Err(err).Msg("failed to reload Operating System site associations for response") + } + + return reloadedOS, ssds, ossas +} + // ~~~~~ GetAll Handler ~~~~~ // // GetAllOperatingSystemHandler is the API Handler for getting all OperatingSystems @@ -691,10 +886,6 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { apiOperatingSystems := []*model.APIOperatingSystem{} for _, os := range oss { - if os.Type == cdbm.OperatingSystemTypeImage { - fmt.Printf("Processing Operating System: %s, Type: %s\n", os.Name, os.Type) - } - curVal := os apiOperatingSystem := model.NewAPIOperatingSystem(&curVal, ssdMap[os.ID.String()], dbossaMap[os.ID], sttsmap) apiOperatingSystems = append(apiOperatingSystems, apiOperatingSystem) @@ -1100,7 +1291,7 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { if apiRequest.IsActive != nil && *apiRequest.IsActive { osStatusMessage = "Operating System has been reactivated and is ready for use" } - if os.Type == cdbm.OperatingSystemTypeImage { + if os.Type == cdbm.OperatingSystemTypeImage || os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusSyncing) osStatusMessage = "received Operating System update request, syncing" } @@ -1126,23 +1317,26 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { } } updatedOs, derr := osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{ - OperatingSystemId: osID, - Name: apiRequest.Name, - Description: apiRequest.Description, - ImageURL: apiRequest.ImageURL, - ImageSHA: apiRequest.ImageSHA, - ImageAuthType: apiRequest.ImageAuthType, - ImageAuthToken: apiRequest.ImageAuthToken, - ImageDisk: apiRequest.ImageDisk, - RootFsId: apiRequest.RootFsID, - RootFsLabel: apiRequest.RootFsLabel, - IpxeScript: apiRequest.IpxeScript, - UserData: apiRequest.UserData, - AllowOverride: apiRequest.AllowOverride, - PhoneHomeEnabled: apiRequest.PhoneHomeEnabled, - IsActive: apiRequest.IsActive, - DeactivationNote: deactivationNote, - Status: osStatus, + OperatingSystemId: osID, + Name: apiRequest.Name, + Description: apiRequest.Description, + ImageURL: apiRequest.ImageURL, + ImageSHA: apiRequest.ImageSHA, + ImageAuthType: apiRequest.ImageAuthType, + ImageAuthToken: apiRequest.ImageAuthToken, + ImageDisk: apiRequest.ImageDisk, + RootFsId: apiRequest.RootFsID, + RootFsLabel: apiRequest.RootFsLabel, + IpxeScript: apiRequest.IpxeScript, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + UserData: apiRequest.UserData, + AllowOverride: apiRequest.AllowOverride, + PhoneHomeEnabled: apiRequest.PhoneHomeEnabled, + IsActive: apiRequest.IsActive, + DeactivationNote: deactivationNote, + Status: osStatus, }) if derr != nil { logger.Error().Err(derr).Msg("error updating Operating System in DB") @@ -1293,6 +1487,26 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return timeoutResp() } + // Push iPXE / Templated iPXE Operating System updates to associated sites via the + // generic Core gRPC proxy (Image OSes are synced in-transaction above). Raw iPXE + // OSes without site associations have nothing to push. + if cdbm.IsIPXEType(uos.Type) { + ipxeOssas, _, oerr := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{uos.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + []string{cdbm.SiteRelationName}) + if oerr != nil { + logger.Error().Err(oerr).Msg("error retrieving Operating System Site associations for proxy sync") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } + if len(ipxeOssas) > 0 { + req := model.BuildUpdateOperatingSystemRequest(uos) + siteErrors := syncOperatingSystemToSitesViaProxy(ctx, logger, ush.dbSession, ush.scp, ipxeOssas, updateOperatingSystemMethod, req) + updateOperatingSystemAggregateStatus(ctx, logger, ush.dbSession, uos.ID, siteErrors > 0) + uos, ssds, dbossas = reloadOperatingSystemForResponse(ctx, logger, ush.dbSession, uos) + } + } + // Send response apiOperatingSystem := model.NewAPIOperatingSystem(uos, ssds, dbossas, sttsmap) logger.Info().Msg("finishing API handler") @@ -1403,7 +1617,10 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { // Verify if Site is in Registered state ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dsh.dbSession) ossasToDelete := []cdbm.OperatingSystemSiteAssociation{} - if os.Type == cdbm.OperatingSystemTypeImage { + // Image and Templated iPXE Operating Systems propagate deletes to their + // associated sites (Image via OsImage workflows, Templated iPXE via the Core + // gRPC proxy), so their associations must be loaded. + if os.Type == cdbm.OperatingSystemTypeImage || os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { ossasToDelete, _, err = ossaDAO.GetAll( ctx, nil, @@ -1418,11 +1635,13 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) } - // Verify if associated Site is not registered state - for _, dbosa := range ossasToDelete { - if dbosa.Site.Status != cdbm.SiteStatusRegistered { - logger.Warn().Msg(fmt.Sprintf("unable to delete Operating System. Site: %s. is not in Registered state", dbosa.SiteID.String())) - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to delete Operating System, Associated Site: %s is not in Registered state", dbosa.Site.Name), nil) + // Verify if associated Site is not registered state (image-based only). + if os.Type == cdbm.OperatingSystemTypeImage { + for _, dbosa := range ossasToDelete { + if dbosa.Site.Status != cdbm.SiteStatusRegistered { + logger.Warn().Msg(fmt.Sprintf("unable to delete Operating System. Site: %s. is not in Registered state", dbosa.SiteID.String())) + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to delete Operating System, Associated Site: %s is not in Registered state", dbosa.Site.Name), nil) + } } } } @@ -1572,6 +1791,26 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { } } + // Templated iPXE Operating Systems mark the OS (and associations) as + // Deleting in-transaction; the deletes are pushed to sites via the Core + // gRPC proxy after commit, and the OS is soft-deleted once all sites are + // cleaned up. + if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE && len(ossasToDelete) > 0 { + if _, derr := osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{OperatingSystemId: os.ID, Status: cutil.GetPtr(cdbm.OperatingSystemStatusDeleting)}); derr != nil { + logger.Error().Err(derr).Msg("error updating Operating System in DB") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to delete Operating System", nil) + } + sdDAO := cdbm.NewStatusDetailDAO(dsh.dbSession) + if _, derr := sdDAO.Create(ctx, tx, cdbm.StatusDetailCreateInput{ + EntityID: os.ID.String(), + Status: cdbm.OperatingSystemStatusDeleting, + Message: cutil.GetPtr("received request for deletion, pending processing"), + }); derr != nil { + logger.Error().Err(derr).Msg("error creating Status Detail DB entry") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to create Status Detail for Operating System", nil) + } + } + // Delete OS if its not Image // Delete OS if there is no Operating Site Association in case of Image based OS if os.Type == cdbm.OperatingSystemTypeIPXE || len(ossasToDelete) == 0 { @@ -1598,6 +1837,44 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return timeoutResp() } + // Push deletes for Templated iPXE Operating Systems to associated sites via the + // generic Core gRPC proxy, remove the synced associations, and soft-delete the + // OS once every site is cleaned up. A not-found object on a site is treated as + // already deleted. + if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE && len(ossasToDelete) > 0 { + req := model.BuildDeleteOperatingSystemRequest(os) + remaining := 0 + for _, ossa := range ossasToDelete { + slogger := logger.With().Str("Site ID", ossa.SiteID.String()).Logger() + stc, cerr := dsh.scp.GetClientByID(ossa.SiteID) + if cerr != nil { + slogger.Error().Err(cerr).Msg("failed to retrieve Temporal client for Site") + remaining++ + continue + } + perr := common.ExecuteCoreGRPC(ctx, stc, deleteOperatingSystemMethod, req, nil, ossa.SiteID.String()) + if perr != nil { + if perr.Code == http.StatusNotFound { + slogger.Warn().Msg("Operating System not found on site, treating delete as successful") + } else { + slogger.Error().Err(perr).Int("code", perr.Code).Msg("failed to delete Operating System on site via Core proxy") + remaining++ + continue + } + } + if derr := ossaDAO.Delete(ctx, nil, ossa.ID); derr != nil { + slogger.Error().Err(derr).Msg("failed to delete Operating System Site Association after site delete") + remaining++ + } + } + if remaining == 0 { + if derr := osDAO.Delete(ctx, nil, os.ID); derr != nil { + logger.Error().Err(derr).Msg("failed to soft-delete Operating System after all sites cleaned up") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System", nil) + } + } + } + // Create response logger.Info().Msg("finishing API handler") return c.JSON(http.StatusAccepted, model.NewAPIDeletionAcceptedResponse()) diff --git a/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go b/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go new file mode 100644 index 0000000000..1179f298a6 --- /dev/null +++ b/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go @@ -0,0 +1,179 @@ +// 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/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" + authz "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization" + "github.com/NVIDIA/infra-controller/rest-api/common/pkg/coreproxy" + "github.com/NVIDIA/infra-controller/rest-api/common/pkg/otelecho" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + tmocks "go.temporal.io/sdk/mocks" +) + +// TestOperatingSystemHandler_TemplatedIPXE_Proxy exercises the full create / +// update / delete lifecycle of a Global-scope Templated iPXE Operating System, +// which is synchronized to its associated Sites through the generic NICo Core +// gRPC proxy (coreproxy.WorkflowName) rather than the dedicated OsImage +// workflows used by Image based Operating Systems. +func TestOperatingSystemHandler_TemplatedIPXE_Proxy(t *testing.T) { + ctx := context.Background() + dbSession := testMachineInitDB(t) + defer dbSession.Close() + + common.TestSetupSchema(t, dbSession) + + cfg := common.GetTestConfig() + + ipOrg := "tmpl-proxy-ip-org" + tnOrg := "tmpl-proxy-tn-org" + + // Tenant admin who owns the OS, plus a provider admin used only to build the + // provider/site fixtures. + ipu := testMachineBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, []string{authz.ProviderAdminRole}) + _ = ipu + tnu := testMachineBuildUser(t, dbSession, uuid.NewString(), []string{tnOrg}, []string{authz.TenantAdminRole}) + + ip := testMachineBuildInfrastructureProvider(t, dbSession, ipOrg, "tmpl-proxy-provider") + site := testMachineBuildSite(t, dbSession, ip, "tmpl-proxy-site", cdbm.SiteStatusRegistered) + + tenant := testMachineBuildTenant(t, dbSession, tnOrg, "tmpl-proxy-tenant") + testBuildTenantSiteAssociation(t, dbSession, tnOrg, tenant.ID, site.ID, tnu.ID) + + // A Public template available at the tenant's site. + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + tmpl, err := templateDAO.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "tmpl-proxy-template", + Template: "#!ipxe\n", + Visibility: "Public", + }) + require.NoError(t, err) + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + _, err = itsaDAO.Create(ctx, nil, cdbm.IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl.ID, SiteID: site.ID}) + require.NoError(t, err) + + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx) + + // Site client pool whose site client answers the Core gRPC proxy workflow + // with success (Get returns nil). + tcfg, _ := cfg.GetTemporalConfig() + scp := sc.NewClientPool(tcfg) + tsc := &tmocks.Client{} + scp.IDClientMap[site.ID.String()] = tsc + + wrun := &tmocks.WorkflowRun{} + wrun.On("GetID").Return("tmpl-proxy-wf-id") + wrun.Mock.On("Get", mock.Anything, mock.Anything).Return(nil) + tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), + coreproxy.WorkflowName, mock.Anything).Return(wrun, nil) + + tc := &tmocks.Client{} + + newEchoContext := func(method, body string, params map[string]string) (echo.Context, *httptest.ResponseRecorder) { + e := echo.New() + req := httptest.NewRequest(method, "/", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ec := e.NewContext(req, rec) + names := make([]string, 0, len(params)) + values := make([]string, 0, len(params)) + for k, v := range params { + names = append(names, k) + values = append(values, v) + } + ec.SetParamNames(names...) + ec.SetParamValues(values...) + ec.Set("user", tnu) + reqCtx := context.WithValue(ctx, otelecho.TracerKey, tracer) + ec.SetRequest(ec.Request().WithContext(reqCtx)) + return ec, rec + } + + var osID string + + t.Run("create Global Templated iPXE syncs to sites via proxy", func(t *testing.T) { + createReq := model.APIOperatingSystemCreateRequest{ + Name: "tmpl-proxy-os", + Description: cutil.GetPtr("templated via proxy"), + IpxeTemplateId: cutil.GetPtr(tmpl.ID.String()), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "version", Value: "22.04"}, + }, + } + body, merr := json.Marshal(createReq) + require.NoError(t, merr) + + ec, rec := newEchoContext(http.MethodPost, string(body), map[string]string{"orgName": tnOrg}) + h := CreateOperatingSystemHandler{dbSession: dbSession, tc: tc, scp: scp, cfg: cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + rsp := &model.APIOperatingSystem{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), rsp)) + require.NotNil(t, rsp.Type) + assert.Equal(t, cdbm.OperatingSystemTypeTemplatedIPXE, *rsp.Type) + require.NotNil(t, rsp.Scope) + assert.Equal(t, cdbm.OperatingSystemScopeGlobal, *rsp.Scope) + // The proxy sync succeeded for the single site, so the aggregate status is Ready. + assert.Equal(t, cdbm.OperatingSystemStatusReady, rsp.Status) + require.Len(t, rsp.SiteAssociations, 1) + assert.Equal(t, cdbm.OperatingSystemSiteAssociationStatusSynced, rsp.SiteAssociations[0].Status) + + osID = rsp.ID + require.NotEmpty(t, osID) + }) + + t.Run("update Templated iPXE re-syncs to sites via proxy", func(t *testing.T) { + require.NotEmpty(t, osID, "create subtest must run first") + updateReq := model.APIOperatingSystemUpdateRequest{ + Description: cutil.GetPtr("templated via proxy - updated"), + IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{ + {Name: "version", Value: "24.04"}, + }, + } + body, merr := json.Marshal(updateReq) + require.NoError(t, merr) + + ec, rec := newEchoContext(http.MethodPatch, string(body), map[string]string{"orgName": tnOrg, "id": osID}) + h := UpdateOperatingSystemHandler{dbSession: dbSession, tc: tc, scp: scp, cfg: cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + rsp := &model.APIOperatingSystem{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), rsp)) + require.NotNil(t, rsp.Description) + assert.Equal(t, "templated via proxy - updated", *rsp.Description) + }) + + t.Run("delete Templated iPXE pushes delete via proxy and soft-deletes OS", func(t *testing.T) { + require.NotEmpty(t, osID, "create subtest must run first") + ec, rec := newEchoContext(http.MethodDelete, "", map[string]string{"orgName": tnOrg, "id": osID}) + h := DeleteOperatingSystemHandler{dbSession: dbSession, tc: tc, scp: scp, cfg: cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusAccepted, rec.Code, "body: %s", rec.Body.String()) + + parsedID, perr := uuid.Parse(osID) + require.NoError(t, perr) + osDAO := cdbm.NewOperatingSystemDAO(dbSession) + _, gerr := osDAO.GetByID(ctx, nil, parsedID, nil) + assert.Error(t, gerr, "OS should be soft-deleted once every site is cleaned up") + }) +} diff --git a/rest-api/api/pkg/api/handler/util/common/testing.go b/rest-api/api/pkg/api/handler/util/common/testing.go index 26d1af33a3..7916f1eaeb 100644 --- a/rest-api/api/pkg/api/handler/util/common/testing.go +++ b/rest-api/api/pkg/api/handler/util/common/testing.go @@ -119,6 +119,12 @@ func TestSetupSchema(t *testing.T, dbSession *cdb.Session) { // create Operating System Site Association table err = dbSession.DB.ResetModel(context.Background(), (*cdbm.OperatingSystemSiteAssociation)(nil)) assert.Nil(t, err) + // create iPXE Template table + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.IpxeTemplate)(nil)) + assert.Nil(t, err) + // create iPXE Template Site Association table (must be after IpxeTemplate due to foreign key) + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.IpxeTemplateSiteAssociation)(nil)) + assert.Nil(t, err) // create Machine table err = dbSession.DB.ResetModel(context.Background(), (*cdbm.Machine)(nil)) assert.Nil(t, err) diff --git a/rest-api/api/pkg/api/model/ipxetemplate.go b/rest-api/api/pkg/api/model/ipxetemplate.go new file mode 100644 index 0000000000..8d83da6f7d --- /dev/null +++ b/rest-api/api/pkg/api/model/ipxetemplate.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "time" + + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" +) + +// APIIpxeTemplate is the data structure to capture the API representation of an iPXE template. +// +// iPXE templates are global in REST and identified by the stable UUID assigned by core +// (`ID`). Per-site availability is tracked separately and not surfaced in this payload. +type APIIpxeTemplate struct { + // ID is the stable template UUID assigned by core, identical between core and REST + ID string `json:"id"` + // Name is the globally unique template name (e.g. "ubuntu-autoinstall", "kernel-initrd") + Name string `json:"name"` + // Template is the raw iPXE script content + Template string `json:"template"` + // RequiredParams lists the parameters that must be provided to render the template + RequiredParams []string `json:"requiredParams"` + // ReservedParams lists the parameters that are reserved by the template and cannot be user-supplied + ReservedParams []string `json:"reservedParams"` + // RequiredArtifacts lists the artifact names (e.g. "kernel", "initrd") required for the template + RequiredArtifacts []string `json:"requiredArtifacts"` + // Visibility indicates the visibility of this template: "Internal" or "Public" + Visibility string `json:"visibility"` + // Created is the date and time the entity was created in this system + Created time.Time `json:"created"` + // Updated is the date and time the entity was last updated in this system + Updated time.Time `json:"updated"` +} + +// NewAPIIpxeTemplate accepts a DB layer IpxeTemplate object and returns an API layer object +func NewAPIIpxeTemplate(dbTemplate *cdbm.IpxeTemplate) *APIIpxeTemplate { + if dbTemplate == nil { + return nil + } + + requiredParams := dbTemplate.RequiredParams + if requiredParams == nil { + requiredParams = []string{} + } + + reservedParams := dbTemplate.ReservedParams + if reservedParams == nil { + reservedParams = []string{} + } + + requiredArtifacts := dbTemplate.RequiredArtifacts + if requiredArtifacts == nil { + requiredArtifacts = []string{} + } + + return &APIIpxeTemplate{ + ID: dbTemplate.ID.String(), + Name: dbTemplate.Name, + Template: dbTemplate.Template, + RequiredParams: requiredParams, + ReservedParams: reservedParams, + RequiredArtifacts: requiredArtifacts, + Visibility: dbTemplate.Visibility, + Created: dbTemplate.Created, + Updated: dbTemplate.Updated, + } +} diff --git a/rest-api/api/pkg/api/model/operatingsystem.go b/rest-api/api/pkg/api/model/operatingsystem.go index be7c3b7c4d..e98b74132f 100644 --- a/rest-api/api/pkg/api/model/operatingsystem.go +++ b/rest-api/api/pkg/api/model/operatingsystem.go @@ -5,6 +5,8 @@ package model import ( "errors" + "fmt" + "strings" "time" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -70,6 +72,29 @@ type APIOperatingSystemCreateRequest struct { AllowOverride bool `json:"allowOverride"` // EnableBlockStorage indicates whether the Operating System image will be stored remotely via block storage EnableBlockStorage bool `json:"enableBlockStorage"` + // IpxeTemplateId is the ID of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and nico-core. + // Allowed values: "Global" (rest->core, all owner sites), "Limited" (rest->core, specific + // sites listed in siteIds). Required for Templated iPXE OS. For raw iPXE OS, only "Global" + // or unspecified is accepted. Rejected for Image OS. + Scope *string `json:"scope"` +} + +// GetOperatingSystemType returns the OperatingSystem type inferred from the +// create request's source fields (`IpxeScript`, `IpxeTemplateId`, or neither). +func (oscr *APIOperatingSystemCreateRequest) GetOperatingSystemType() string { + if oscr.IpxeScript != nil { + return cdbm.OperatingSystemTypeIPXE + } + if oscr.IpxeTemplateId != nil { + return cdbm.OperatingSystemTypeTemplatedIPXE + } + return cdbm.OperatingSystemTypeImage } // Validate ensure the values passed in request are acceptable @@ -88,6 +113,18 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { return err } + if oscr.IpxeTemplateId != nil && strings.TrimSpace(*oscr.IpxeTemplateId) == "" { + return validation.Errors{ + "ipxeTemplateId": errors.New("must not be empty"), + } + } + + if oscr.IpxeScript != nil && oscr.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("ipxeScript and ipxeTemplateId are mutually exclusive"), + } + } + // Make sure siteIds only required in case of image is OS based if oscr.IpxeScript != nil && len(oscr.SiteIDs) > 0 { return validation.Errors{ @@ -95,13 +132,13 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { } } - if oscr.IpxeScript != nil && oscr.ImageURL != nil { + if (oscr.IpxeScript != nil || oscr.IpxeTemplateId != nil) && oscr.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), } - } else if oscr.IpxeScript == nil && oscr.ImageURL == nil { + } else if oscr.IpxeScript == nil && oscr.IpxeTemplateId == nil && oscr.ImageURL == nil { return validation.Errors{ - validationCommonErrorField: errors.New("either imageURL or ipxeScript must be specified"), + validationCommonErrorField: errors.New("one of imageURL, ipxeScript, or ipxeTemplateId must be specified"), } } @@ -111,6 +148,42 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { } } + // iPXE template definition fields are only valid for Templated iPXE Operating Systems. + if oscr.IpxeTemplateId == nil { + if len(oscr.IpxeTemplateParameters) > 0 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("can only be specified for Templated iPXE Operating Systems"), + } + } + if len(oscr.IpxeTemplateArtifacts) > 0 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("can only be specified for Templated iPXE Operating Systems"), + } + } + } + + // Scope handling differs by OS type. Templated iPXE is validated in full by + // validateTemplatedIpxeOS (including its own image-field/site-id rules), so it + // returns early and never falls through to the image/raw-iPXE checks below. + switch { + case oscr.IpxeTemplateId != nil: + return oscr.validateTemplatedIpxeOS() + case oscr.IpxeScript != nil: + // raw iPXE: scope is optional but must be Global when set. + if oscr.Scope != nil && *oscr.Scope != cdbm.OperatingSystemScopeGlobal { + return validation.Errors{ + "scope": fmt.Errorf("scope must be %q or unspecified for raw iPXE Operating Systems", cdbm.OperatingSystemScopeGlobal), + } + } + default: + // Image: scope is not applicable. + if oscr.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope can only be specified for Templated iPXE Operating Systems"), + } + } + } + if oscr.ImageURL != nil { err = validation.ValidateStruct(oscr, validation.Field(&oscr.ImageURL, is.URL), @@ -284,6 +357,14 @@ type APIOperatingSystemUpdateRequest struct { IsActive *bool `json:"isActive"` // DeactivationNote is the deactivation note if any DeactivationNote *string `json:"deactivationNote"` + // IpxeTemplateId is the ID of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters *[]cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition + IpxeTemplateArtifacts *[]cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope is immutable after creation. If provided, the request is rejected. + Scope *string `json:"scope"` } // Validate ensure the values passed in request are acceptable @@ -319,6 +400,69 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating } } + // Scope is immutable after creation. + if osur.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope cannot be changed after creation"), + } + } + + // iPXE script and template are mutually exclusive in a single request. + if osur.IpxeScript != nil && osur.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("ipxeScript and ipxeTemplateId are mutually exclusive"), + } + } + if osur.IpxeTemplateId != nil && strings.TrimSpace(*osur.IpxeTemplateId) == "" { + return validation.Errors{ + "ipxeTemplateId": errors.New("must not be empty"), + } + } + if osur.IpxeTemplateId != nil && osur.ImageURL != nil { + return validation.Errors{ + "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), + } + } + + // Reject cross-type field assignments based on the existing OS type and + // validate iPXE template definition fields (Templated iPXE only). + switch existingOS.Type { + case cdbm.OperatingSystemTypeImage: + if osur.IpxeTemplateId != nil { + return validation.Errors{"ipxeTemplateId": errors.New("unable to set iPXE template for image based Operating System")} + } + case cdbm.OperatingSystemTypeIPXE: + if osur.IpxeTemplateId != nil { + return validation.Errors{"ipxeTemplateId": errors.New("unable to set iPXE template for raw iPXE Operating System")} + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + if osur.IpxeScript != nil { + return validation.Errors{"ipxeScript": errors.New("unable to set iPXE script for templated iPXE Operating System")} + } + if osur.ImageURL != nil { + return validation.Errors{"imageURL": errors.New("unable to set image URL for iPXE based Operating System")} + } + } + if existingOS.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + if osur.IpxeTemplateParameters != nil { + if verr := validateIpxeTemplateParameters(*osur.IpxeTemplateParameters); verr != nil { + return verr + } + } + if osur.IpxeTemplateArtifacts != nil { + if verr := validateIpxeTemplateArtifacts(*osur.IpxeTemplateArtifacts); verr != nil { + return verr + } + } + } else { + if osur.IpxeTemplateParameters != nil { + return validation.Errors{"ipxeTemplateParameters": errors.New("can only be specified for Templated iPXE Operating Systems")} + } + if osur.IpxeTemplateArtifacts != nil { + return validation.Errors{"ipxeTemplateArtifacts": errors.New("can only be specified for Templated iPXE Operating Systems")} + } + } + if osur.IpxeScript != nil && osur.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), @@ -597,6 +741,16 @@ type APIOperatingSystem struct { RootFsLabel *string `json:"rootFsLabel"` // IpxeScript is the ipxe ocript for the Operating System IpxeScript *string `json:"ipxeScript"` + // IpxeTemplateId is the ID of the iPXE template used by this Operating System + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters passed to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition. + // Any artifact authToken is redacted in API responses. + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and nico-core. + // One of "Local", "Global", or "Limited". Only set for iPXE-based Operating Systems. + Scope *string `json:"scope"` // PhoneHomeEnabled is an attribute which is specified by user if Operating System needs to be enabled for phone home or not PhoneHomeEnabled bool `json:"phoneHomeEnabled"` // UserData is the user data for the Operating System @@ -639,6 +793,8 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail RootFsID: dbOS.RootFsID, RootFsLabel: dbOS.RootFsLabel, IpxeScript: dbOS.IpxeScript, + IpxeTemplateId: dbOS.IpxeTemplateId, + Scope: dbOS.IpxeOsScope, PhoneHomeEnabled: dbOS.PhoneHomeEnabled, UserData: dbOS.UserData, IsCloudInit: IsCloudInitFromUserData(dbOS.UserData), @@ -650,6 +806,16 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail Created: dbOS.Created, Updated: dbOS.Updated, } + apiOperatingSystem.IpxeTemplateParameters = dbOS.IpxeTemplateParameters + // Redact artifact auth tokens: never echo stored secrets back to clients. + if dbOS.IpxeTemplateArtifacts != nil { + redactedArtifacts := make([]cdbm.OperatingSystemIpxeArtifact, len(dbOS.IpxeTemplateArtifacts)) + for i, artifact := range dbOS.IpxeTemplateArtifacts { + artifact.AuthToken = nil + redactedArtifacts[i] = artifact + } + apiOperatingSystem.IpxeTemplateArtifacts = redactedArtifacts + } if dbOS.InfrastructureProviderID != nil { apiOperatingSystem.InfrastructureProviderID = cutil.GetPtr(dbOS.InfrastructureProviderID.String()) } @@ -699,3 +865,168 @@ func NewAPIOperatingSystemSummary(dbos *cdbm.OperatingSystem) *APIOperatingSyste return &aos } + +// validateTemplatedIpxeOS fully validates a Templated iPXE create request: image +// fields must be absent, scope must be Global or Limited (Local is rejected at +// creation), siteIds are required only for Limited scope, and the template +// parameters/artifacts must be well-formed. +func (oscr *APIOperatingSystemCreateRequest) validateTemplatedIpxeOS() error { + if err := validation.ValidateStruct(oscr, + validation.Field(&oscr.ImageSHA, validation.Nil.Error("imageSHA cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.ImageAuthType, validation.Nil.Error("imageAuthType cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.ImageAuthToken, validation.Nil.Error("imageAuthToken cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.ImageDisk, validation.Nil.Error("imageDisk cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.RootFsID, validation.Nil.Error("rootFsId cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.RootFsLabel, validation.Nil.Error("rootFsLabel cannot be specified for Templated iPXE Operating Systems")), + ); err != nil { + return err + } + + if oscr.Scope == nil { + return validation.Errors{"scope": errors.New("scope is required for Templated iPXE Operating Systems")} + } + switch *oscr.Scope { + case cdbm.OperatingSystemScopeGlobal, cdbm.OperatingSystemScopeLimited: + // valid + case cdbm.OperatingSystemScopeLocal: + return validation.Errors{"scope": errors.New("scope 'Local' cannot be specified at creation; Local Operating Systems are created in nico-core")} + default: + return validation.Errors{"scope": errors.New("scope must be one of 'Global' or 'Limited'")} + } + + if len(oscr.SiteIDs) > 0 && *oscr.Scope != cdbm.OperatingSystemScopeLimited { + return validation.Errors{"siteIds": errors.New("siteIds can only be specified for Templated iPXE Operating Systems with scope 'Limited'")} + } + if *oscr.Scope == cdbm.OperatingSystemScopeLimited && len(oscr.SiteIDs) == 0 { + return validation.Errors{"siteIds": errors.New("at least one siteId must be specified when scope is 'Limited'")} + } + + if err := validateIpxeTemplateParameters(oscr.IpxeTemplateParameters); err != nil { + return err + } + if err := validateIpxeTemplateArtifacts(oscr.IpxeTemplateArtifacts); err != nil { + return err + } + return nil +} + +// validCacheStrategies is the set of accepted artifact CacheStrategy string values. +// It is derived from the DB model's strategy map so the API and persistence layers +// agree on the canonical (friendly) strategy names. +var validCacheStrategies = func() map[string]struct{} { + m := make(map[string]struct{}, len(cdbm.OperatingSystemIpxeArtifactCacheStrategyToProtoMap)) + for name := range cdbm.OperatingSystemIpxeArtifactCacheStrategyToProtoMap { + m[name] = struct{}{} + } + return m +}() + +func validateIpxeTemplateParameters(params []cdbm.OperatingSystemIpxeParameter) error { + for i, p := range params { + if strings.TrimSpace(p.Name) == "" { + return validation.Errors{"ipxeTemplateParameters": fmt.Errorf("entry %d: name is required", i)} + } + } + return nil +} + +func validateIpxeTemplateArtifacts(artifacts []cdbm.OperatingSystemIpxeArtifact) error { + for i, a := range artifacts { + if strings.TrimSpace(a.Name) == "" { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d: name is required", i)} + } + if strings.TrimSpace(a.URL) == "" { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): url is required", i, a.Name)} + } + if _, ok := validCacheStrategies[a.CacheStrategy]; !ok { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): cacheStrategy must be one of CacheAsNeeded, LocalOnly, CachedOnly, RemoteOnly", i, a.Name)} + } + if a.AuthType != nil && *a.AuthType != "" { + at := *a.AuthType + if at != cdbm.OperatingSystemAuthTypeBasic && at != cdbm.OperatingSystemAuthTypeBearer { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authType must be Basic or Bearer", i, a.Name)} + } + if a.AuthToken == nil || *a.AuthToken == "" { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authToken is required when authType is specified", i, a.Name)} + } + } + if a.AuthToken != nil && *a.AuthToken != "" && (a.AuthType == nil || *a.AuthType == "") { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authType must be specified when authToken is provided", i, a.Name)} + } + } + return nil +} + +// BuildCreateOperatingSystemRequest builds the forge.Forge CreateOperatingSystem +// request proto from a persisted Operating System record. It is used by the OS +// handler to push iPXE / Templated iPXE definitions to on-site NICo Core through +// the generic Core gRPC proxy. +// +// Note: artifact authTokens are nested inside the repeated artifacts message and +// are therefore carried as-is (the proxy cannot redact nested fields). +func BuildCreateOperatingSystemRequest(os *cdbm.OperatingSystem) *corev1.CreateOperatingSystemRequest { + return &corev1.CreateOperatingSystemRequest{ + Id: &corev1.OperatingSystemId{Value: os.ID.String()}, + Name: os.Name, + Description: os.Description, + TenantOrganizationId: os.Org, + IsActive: os.IsActive, + AllowOverride: os.AllowOverride, + PhoneHomeEnabled: os.PhoneHomeEnabled, + UserData: os.UserData, + IpxeScript: os.IpxeScript, + IpxeTemplateId: ipxeTemplateIDProto(os.IpxeTemplateId), + IpxeTemplateParameters: ipxeParametersProto(os.IpxeTemplateParameters), + IpxeTemplateArtifacts: ipxeArtifactsProto(os.IpxeTemplateArtifacts), + } +} + +// BuildUpdateOperatingSystemRequest builds the forge.Forge UpdateOperatingSystem +// request proto from a persisted Operating System record. +func BuildUpdateOperatingSystemRequest(os *cdbm.OperatingSystem) *corev1.UpdateOperatingSystemRequest { + return &corev1.UpdateOperatingSystemRequest{ + Id: &corev1.OperatingSystemId{Value: os.ID.String()}, + Name: &os.Name, + Description: os.Description, + IsActive: &os.IsActive, + AllowOverride: &os.AllowOverride, + PhoneHomeEnabled: &os.PhoneHomeEnabled, + UserData: os.UserData, + IpxeScript: os.IpxeScript, + IpxeTemplateId: ipxeTemplateIDProto(os.IpxeTemplateId), + IpxeTemplateParameters: &corev1.IpxeTemplateParameters{Items: ipxeParametersProto(os.IpxeTemplateParameters)}, + IpxeTemplateArtifacts: &corev1.IpxeTemplateArtifacts{Items: ipxeArtifactsProto(os.IpxeTemplateArtifacts)}, + IpxeTemplateDefinitionHash: os.IpxeTemplateDefinitionHash, + } +} + +// BuildDeleteOperatingSystemRequest builds the forge.Forge DeleteOperatingSystem +// request proto for a persisted Operating System record. +func BuildDeleteOperatingSystemRequest(os *cdbm.OperatingSystem) *corev1.DeleteOperatingSystemRequest { + return &corev1.DeleteOperatingSystemRequest{ + Id: &corev1.OperatingSystemId{Value: os.ID.String()}, + } +} + +func ipxeTemplateIDProto(id *string) *corev1.IpxeTemplateId { + if id == nil { + return nil + } + return &corev1.IpxeTemplateId{Value: *id} +} + +func ipxeParametersProto(params []cdbm.OperatingSystemIpxeParameter) []*corev1.IpxeTemplateParameter { + out := make([]*corev1.IpxeTemplateParameter, 0, len(params)) + for i := range params { + out = append(out, params[i].ToProto()) + } + return out +} + +func ipxeArtifactsProto(artifacts []cdbm.OperatingSystemIpxeArtifact) []*corev1.IpxeTemplateArtifact { + out := make([]*corev1.IpxeTemplateArtifact, 0, len(artifacts)) + for i := range artifacts { + out = append(out, artifacts[i].ToProto()) + } + return out +} diff --git a/rest-api/api/pkg/api/model/operatingsystem_templated_test.go b/rest-api/api/pkg/api/model/operatingsystem_templated_test.go new file mode 100644 index 0000000000..3d8e5022db --- /dev/null +++ b/rest-api/api/pkg/api/model/operatingsystem_templated_test.go @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + 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" +) + +func TestOperatingSystemCreateRequest_GetOperatingSystemType(t *testing.T) { + assert.Equal(t, cdbm.OperatingSystemTypeIPXE, (&APIOperatingSystemCreateRequest{IpxeScript: cutil.GetPtr("x")}).GetOperatingSystemType()) + assert.Equal(t, cdbm.OperatingSystemTypeTemplatedIPXE, (&APIOperatingSystemCreateRequest{IpxeTemplateId: cutil.GetPtr("t")}).GetOperatingSystemType()) + assert.Equal(t, cdbm.OperatingSystemTypeImage, (&APIOperatingSystemCreateRequest{ImageURL: cutil.GetPtr("http://x")}).GetOperatingSystemType()) +} + +func TestOperatingSystemCreateRequest_Validate_TemplatedAndScope(t *testing.T) { + tmplID := cutil.GetPtr("tmpl-1") + tests := []struct { + desc string + obj APIOperatingSystemCreateRequest + expectErr bool + }{ + { + desc: "templated requires scope", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID}, + expectErr: true, + }, + { + desc: "templated rejects Local scope at create", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLocal)}, + expectErr: true, + }, + { + desc: "templated global is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal)}, + expectErr: false, + }, + { + desc: "templated global rejects siteIds", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), SiteIDs: []string{uuid.NewString()}}, + expectErr: true, + }, + { + desc: "templated limited requires siteIds", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited)}, + expectErr: true, + }, + { + desc: "templated limited with siteIds is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited), SiteIDs: []string{uuid.NewString()}}, + expectErr: false, + }, + { + desc: "templated artifact with valid cache strategy is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://x/k", CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}}}, + expectErr: false, + }, + { + desc: "templated artifact with invalid cache strategy is rejected", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://x/k", CacheStrategy: "BOGUS"}}}, + expectErr: true, + }, + { + desc: "templated artifact missing url is rejected", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}}}, + expectErr: true, + }, + { + desc: "raw ipxe rejects template parameters", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{{Name: "p", Value: "v"}}}, + expectErr: true, + }, + { + desc: "raw ipxe rejects non-global scope", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited)}, + expectErr: true, + }, + { + desc: "ipxeScript and ipxeTemplateId mutually exclusive", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), IpxeTemplateId: tmplID}, + expectErr: true, + }, + { + desc: "image rejects scope", + obj: APIOperatingSystemCreateRequest{Name: "abc", ImageURL: cutil.GetPtr("http://iso.net/iso"), ImageSHA: cutil.GetPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), RootFsID: cutil.GetPtr("666c2eee-193d-42db-a490-4c444342bd4e"), SiteIDs: []string{uuid.NewString()}, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal)}, + expectErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + err := tc.obj.Validate() + assert.Equal(t, tc.expectErr, err != nil) + }) + } +} + +func TestOperatingSystemUpdateRequest_Validate_ScopeImmutableAndTemplate(t *testing.T) { + templatedOS := &cdbm.OperatingSystem{ID: uuid.New(), Name: "ab", Type: cdbm.OperatingSystemTypeTemplatedIPXE, Status: cdbm.OperatingSystemStatusReady} + rawIpxeOS := &cdbm.OperatingSystem{ID: uuid.New(), Name: "ab", Type: cdbm.OperatingSystemTypeIPXE, IpxeScript: cutil.GetPtr("x"), Status: cdbm.OperatingSystemStatusReady} + + t.Run("scope is immutable", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal)}).Validate(templatedOS) + assert.Error(t, err) + }) + t.Run("templated accepts template params", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "p", Value: "v"}}}).Validate(templatedOS) + assert.NoError(t, err) + }) + t.Run("raw ipxe rejects template params", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "p", Value: "v"}}}).Validate(rawIpxeOS) + assert.Error(t, err) + }) + t.Run("raw ipxe rejects template id", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateId: cutil.GetPtr("t")}).Validate(rawIpxeOS) + assert.Error(t, err) + }) +} + +func TestBuildOperatingSystemRequests(t *testing.T) { + id := uuid.New() + authToken := "secret-token" + os := &cdbm.OperatingSystem{ + ID: id, + Name: "templated-os", + Description: cutil.GetPtr("desc"), + Org: "org-1", + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + IsActive: true, + AllowOverride: true, + PhoneHomeEnabled: true, + UserData: cutil.GetPtr("ud"), + IpxeTemplateId: cutil.GetPtr("tmpl-1"), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "version", Value: "22.04"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://x/k", AuthType: cutil.GetPtr(cdbm.OperatingSystemAuthTypeBearer), AuthToken: &authToken, CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}, + }, + IpxeTemplateDefinitionHash: cutil.GetPtr("hash-1"), + } + + t.Run("create request maps all fields", func(t *testing.T) { + req := BuildCreateOperatingSystemRequest(os) + require.NotNil(t, req) + assert.Equal(t, id.String(), req.GetId().GetValue()) + assert.Equal(t, "templated-os", req.Name) + assert.Equal(t, "org-1", req.TenantOrganizationId) + assert.True(t, req.IsActive) + assert.True(t, req.AllowOverride) + assert.True(t, req.PhoneHomeEnabled) + assert.Equal(t, "tmpl-1", req.GetIpxeTemplateId().GetValue()) + require.Len(t, req.IpxeTemplateParameters, 1) + assert.Equal(t, "version", req.IpxeTemplateParameters[0].Name) + require.Len(t, req.IpxeTemplateArtifacts, 1) + assert.Equal(t, "kernel", req.IpxeTemplateArtifacts[0].Name) + // Cache strategy maps from the friendly name to the proto enum. + assert.Equal(t, corev1.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED, req.IpxeTemplateArtifacts[0].CacheStrategy) + // CachedUrl is never emitted from the rest side. + assert.Nil(t, req.IpxeTemplateArtifacts[0].CachedUrl) + }) + + t.Run("update request maps all fields", func(t *testing.T) { + req := BuildUpdateOperatingSystemRequest(os) + require.NotNil(t, req) + assert.Equal(t, id.String(), req.GetId().GetValue()) + require.NotNil(t, req.Name) + assert.Equal(t, "templated-os", *req.Name) + assert.Equal(t, "tmpl-1", req.GetIpxeTemplateId().GetValue()) + require.NotNil(t, req.IpxeTemplateParameters) + require.Len(t, req.IpxeTemplateParameters.Items, 1) + require.NotNil(t, req.IpxeTemplateArtifacts) + require.Len(t, req.IpxeTemplateArtifacts.Items, 1) + require.NotNil(t, req.IpxeTemplateDefinitionHash) + assert.Equal(t, "hash-1", *req.IpxeTemplateDefinitionHash) + }) + + t.Run("delete request maps id", func(t *testing.T) { + req := BuildDeleteOperatingSystemRequest(os) + require.NotNil(t, req) + assert.Equal(t, id.String(), req.GetId().GetValue()) + }) +} + +func TestNewAPIOperatingSystem_RedactsArtifactAuthToken(t *testing.T) { + authToken := "super-secret" + dbOS := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "templated", + Org: "org-1", + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + IpxeTemplateId: cutil.GetPtr("tmpl-1"), + IpxeOsScope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://x/k", AuthType: cutil.GetPtr(cdbm.OperatingSystemAuthTypeBearer), AuthToken: &authToken, CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}, + }, + } + + api := NewAPIOperatingSystem(dbOS, nil, nil, nil) + require.NotNil(t, api) + require.NotNil(t, api.Scope) + assert.Equal(t, cdbm.OperatingSystemScopeGlobal, *api.Scope) + require.Len(t, api.IpxeTemplateArtifacts, 1) + assert.Nil(t, api.IpxeTemplateArtifacts[0].AuthToken, "artifact authToken must be redacted in API responses") + // The source DB object must not be mutated by the redaction copy. + require.NotNil(t, dbOS.IpxeTemplateArtifacts[0].AuthToken) + assert.Equal(t, "super-secret", *dbOS.IpxeTemplateArtifacts[0].AuthToken) +} diff --git a/rest-api/api/pkg/api/routes.go b/rest-api/api/pkg/api/routes.go index 1c499eaa2a..78c6f07e9e 100644 --- a/rest-api/api/pkg/api/routes.go +++ b/rest-api/api/pkg/api/routes.go @@ -723,6 +723,17 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa Method: http.MethodDelete, Handler: apiHandler.NewDeleteOperatingSystemHandler(dbSession, tc, scp, cfg), }, + // iPXE Template endpoints (read-only; templates are synced from nico-core) + { + Path: apiPathPrefix + "/ipxe-template", + Method: http.MethodGet, + Handler: apiHandler.NewGetAllIpxeTemplateHandler(dbSession, tc, cfg), + }, + { + Path: apiPathPrefix + "/ipxe-template/:id", + Method: http.MethodGet, + Handler: apiHandler.NewGetIpxeTemplateHandler(dbSession, tc, cfg), + }, // NetworkSecurityGroup endpoints { Path: apiPathPrefix + "/network-security-group", diff --git a/rest-api/api/pkg/api/routes_test.go b/rest-api/api/pkg/api/routes_test.go index c6de7acad0..d417dd334c 100644 --- a/rest-api/api/pkg/api/routes_test.go +++ b/rest-api/api/pkg/api/routes_test.go @@ -62,6 +62,7 @@ func TestNewAPIRoutes(t *testing.T) { "machine-instance-type": 3, "user": 1, "operating-system": 5, + "ipxe-template": 2, "sshkey": 5, "sshkeygroup": 5, "machine-capability": 1, diff --git a/rest-api/openapi/spec.yaml b/rest-api/openapi/spec.yaml index 51c04761e4..6e75ba259f 100644 --- a/rest-api/openapi/spec.yaml +++ b/rest-api/openapi/spec.yaml @@ -8018,11 +8018,9 @@ paths: $ref: '#/components/responses/ForbiddenError' operationId: get-all-operating-system description: |- - Get an Operating System by ID - - If the Operating System has `infrastructureProviderId` set, then org must have an Infrastructure Provider entity and its ID should match the Operating System Infrastructure Provider ID. User must have authorization role with `PROVIDER_ADMIN` suffix. + List Operating Systems visible to the caller's Tenant. - If the Operating System has `tenantId` set, then org must have a Tenant entity and its ID should match the Operating System Tenant ID. User must have authorization role with `TENANT_ADMIN` suffix. + Org must have a Tenant entity. User must have authorization role with `TENANT_ADMIN` suffix. Only Operating Systems whose `tenantId` matches the caller's Tenant are returned. parameters: - schema: type: string @@ -8034,6 +8032,7 @@ paths: enum: - Image - iPXE + - Templated iPXE in: query name: type description: Filter Operating Systems by Type @@ -11711,6 +11710,88 @@ paths: $ref: '#/components/responses/NotFoundError' tags: - Tray + '/v2/org/{org}/nico/ipxe-template': + parameters: + - schema: + type: string + name: org + in: path + required: true + description: Name of the Org + get: + summary: Get all iPXE templates + description: 'Get all iPXE templates propagated from nico-core. Optionally restrict to one or more sites with the siteId query parameter.' + operationId: get-all-ipxe-template + tags: + - iPXE Template + parameters: + - schema: + type: array + items: + type: string + format: uuid + name: siteId + in: query + required: false + description: 'Optional site ID(s); may be repeated to restrict results to templates available at any of the sites' + - schema: + type: integer + name: pageNumber + in: query + required: false + - schema: + type: integer + name: pageSize + in: query + required: false + - schema: + type: string + name: orderBy + in: query + required: false + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/IpxeTemplate' + '403': + $ref: '#/components/responses/ForbiddenError' + '/v2/org/{org}/nico/ipxe-template/{ipxeTemplateId}': + parameters: + - schema: + type: string + name: org + in: path + required: true + description: Name of the Org + - schema: + type: string + format: uuid + name: ipxeTemplateId + in: path + required: true + description: Stable template ID (UUID from core) + get: + summary: Retrieve an iPXE template + description: 'Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is available.' + operationId: get-ipxe-template + tags: + - iPXE Template + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/IpxeTemplate' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' '/v2/org/{org}/nico/network-security-group': parameters: - schema: @@ -16773,6 +16854,7 @@ components: enum: - iPXE - Image + - Templated iPXE imageUrl: type: - string @@ -16814,6 +16896,26 @@ components: - string - 'null' description: 'iPXE script or URL, only applicable for iPXE-based Operating System' + ipxeTemplateId: + type: + - string + - 'null' + description: 'ID of the iPXE template used, only present for Templated iPXE Operating System' + ipxeTemplateParameters: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeParameter' + description: 'Parameters passed to the iPXE template (Templated iPXE only)' + ipxeTemplateArtifacts: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeArtifact' + description: 'Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted.' + scope: + type: + - string + - 'null' + description: 'Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited)' userData: type: - string @@ -16871,6 +16973,111 @@ components: - Deleting - Error - Deactivated + OperatingSystemIpxeParameter: + title: OperatingSystemIpxeParameter + type: object + description: A name/value parameter passed to an iPXE template + properties: + name: + type: string + description: Parameter name (used as a variable in the template) + value: + type: string + description: Parameter value + OperatingSystemIpxeArtifact: + title: OperatingSystemIpxeArtifact + type: object + description: An artifact (kernel, initrd, ISO, ...) referenced by an iPXE OS definition + properties: + name: + type: string + description: Artifact name + url: + type: string + description: Original URL for the artifact + sha: + type: + - string + - 'null' + description: Optional SHA256 checksum + authType: + type: + - string + - 'null' + description: 'Optional auth type: Basic or Bearer' + authToken: + type: + - string + - 'null' + description: 'Optional auth token. Redacted in API responses.' + cacheStrategy: + type: string + enum: + - CacheAsNeeded + - LocalOnly + - CachedOnly + - RemoteOnly + description: How to handle caching for this artifact + IpxeTemplate: + title: IpxeTemplate + type: object + description: An iPXE script template propagated (read-only) from nico-core + examples: + - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6 + name: ubuntu-autoinstall + template: | + #!ipxe + kernel ${kernel_url} initrd=initrd autoinstall + initrd ${initrd_url} + boot + requiredParams: + - kernel_url + - initrd_url + reservedParams: + - mac + requiredArtifacts: + - kernel + - initrd + visibility: Public + created: '2026-07-14T14:15:22Z' + updated: '2026-07-14T14:15:22Z' + properties: + id: + type: string + format: uuid + description: Stable template UUID assigned by core + name: + type: string + description: Globally unique template name + template: + type: string + description: Raw iPXE script content + requiredParams: + type: array + items: + type: string + description: Parameters that must be provided to render the template + reservedParams: + type: array + items: + type: string + description: Parameters reserved by the template and not user-supplied + requiredArtifacts: + type: array + items: + type: string + description: Artifact names required for the template + visibility: + type: string + description: 'Template visibility: Internal or Public' + created: + type: string + format: date-time + readOnly: true + updated: + type: string + format: date-time + readOnly: true OperatingSystemSiteAssociation: title: OperatingSystemSiteAssociation type: object @@ -17046,6 +17253,31 @@ components: allowOverride: type: boolean description: Indicates if the user data can be overridden at Instance creation time + ipxeTemplateId: + type: + - string + - 'null' + description: 'ID of the iPXE template to use; identifies a Templated iPXE Operating System. Mutually exclusive with ipxeScript and imageUrl.' + ipxeTemplateParameters: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeParameter' + description: 'Parameters passed to the iPXE template (Templated iPXE only).' + ipxeTemplateArtifacts: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeArtifact' + description: 'Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only).' + scope: + type: + - string + - 'null' + enum: + - Local + - Global + - Limited + - null + description: 'Synchronization scope for iPXE-based Operating Systems. Required for Templated iPXE (Global or Limited; Local is created only in nico-core).' required: - name OperatingSystemUpdateRequest: @@ -17152,6 +17384,26 @@ components: - string - 'null' description: Optional deactivation note if OS is inactive + ipxeTemplateId: + type: + - string + - 'null' + description: 'ID of the iPXE template to use (Templated iPXE only). Mutually exclusive with ipxeScript and imageUrl.' + ipxeTemplateParameters: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeParameter' + description: 'Parameters passed to the iPXE template (Templated iPXE only).' + ipxeTemplateArtifacts: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeArtifact' + description: 'Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only).' + scope: + type: + - string + - 'null' + description: 'Scope is immutable after creation; if provided the request is rejected.' InstanceType: title: InstanceType type: object diff --git a/rest-api/sdk/standard/api_ipxe_template.go b/rest-api/sdk/standard/api_ipxe_template.go new file mode 100644 index 0000000000..7832585af0 --- /dev/null +++ b/rest-api/sdk/standard/api_ipxe_template.go @@ -0,0 +1,312 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "reflect" + "strings" +) + +// IPXETemplateAPIService IPXETemplateAPI service +type IPXETemplateAPIService service + +type ApiGetAllIpxeTemplateRequest struct { + ctx context.Context + ApiService *IPXETemplateAPIService + org string + siteId *[]string + pageNumber *int32 + pageSize *int32 + orderBy *string +} + +// Optional site ID(s); may be repeated to restrict results to templates available at any of the sites +func (r ApiGetAllIpxeTemplateRequest) SiteId(siteId []string) ApiGetAllIpxeTemplateRequest { + r.siteId = &siteId + return r +} + +func (r ApiGetAllIpxeTemplateRequest) PageNumber(pageNumber int32) ApiGetAllIpxeTemplateRequest { + r.pageNumber = &pageNumber + return r +} + +func (r ApiGetAllIpxeTemplateRequest) PageSize(pageSize int32) ApiGetAllIpxeTemplateRequest { + r.pageSize = &pageSize + return r +} + +func (r ApiGetAllIpxeTemplateRequest) OrderBy(orderBy string) ApiGetAllIpxeTemplateRequest { + r.orderBy = &orderBy + return r +} + +func (r ApiGetAllIpxeTemplateRequest) Execute() ([]IpxeTemplate, *http.Response, error) { + return r.ApiService.GetAllIpxeTemplateExecute(r) +} + +/* +GetAllIpxeTemplate Get all iPXE templates + +Get all iPXE templates propagated from nico-core. Optionally restrict to one or more sites with the siteId query parameter. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Org + @return ApiGetAllIpxeTemplateRequest +*/ +func (a *IPXETemplateAPIService) GetAllIpxeTemplate(ctx context.Context, org string) ApiGetAllIpxeTemplateRequest { + return ApiGetAllIpxeTemplateRequest{ + ApiService: a, + ctx: ctx, + org: org, + } +} + +// Execute executes the request +// +// @return []IpxeTemplate +func (a *IPXETemplateAPIService) GetAllIpxeTemplateExecute(r ApiGetAllIpxeTemplateRequest) ([]IpxeTemplate, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue []IpxeTemplate + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IPXETemplateAPIService.GetAllIpxeTemplate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/ipxe-template" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.siteId != nil { + t := *r.siteId + if reflect.TypeOf(t).Kind() == reflect.Slice { + s := reflect.ValueOf(t) + for i := 0; i < s.Len(); i++ { + parameterAddToHeaderOrQuery(localVarQueryParams, "siteId", s.Index(i).Interface(), "form", "multi") + } + } else { + parameterAddToHeaderOrQuery(localVarQueryParams, "siteId", t, "form", "multi") + } + } + if r.pageNumber != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") + } + if r.pageSize != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "pageSize", r.pageSize, "form", "") + } + if r.orderBy != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderBy", r.orderBy, "form", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // 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 + } + 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 == 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 +} + +type ApiGetIpxeTemplateRequest struct { + ctx context.Context + ApiService *IPXETemplateAPIService + org string + ipxeTemplateId string +} + +func (r ApiGetIpxeTemplateRequest) Execute() (*IpxeTemplate, *http.Response, error) { + return r.ApiService.GetIpxeTemplateExecute(r) +} + +/* +GetIpxeTemplate Retrieve an iPXE template + +Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is available. + + @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 ipxeTemplateId Stable template ID (UUID from core) + @return ApiGetIpxeTemplateRequest +*/ +func (a *IPXETemplateAPIService) GetIpxeTemplate(ctx context.Context, org string, ipxeTemplateId string) ApiGetIpxeTemplateRequest { + return ApiGetIpxeTemplateRequest{ + ApiService: a, + ctx: ctx, + org: org, + ipxeTemplateId: ipxeTemplateId, + } +} + +// Execute executes the request +// +// @return IpxeTemplate +func (a *IPXETemplateAPIService) GetIpxeTemplateExecute(r ApiGetIpxeTemplateRequest) (*IpxeTemplate, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *IpxeTemplate + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IPXETemplateAPIService.GetIpxeTemplate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/ipxe-template/{ipxeTemplateId}" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"ipxeTemplateId"+"}", url.PathEscape(parameterValueToString(r.ipxeTemplateId, "ipxeTemplateId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // 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 + } + 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 == 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 + } + if localVarHTTPResponse.StatusCode == 404 { + 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/rest-api/sdk/standard/client.go b/rest-api/sdk/standard/client.go index f457710425..5c08bc125d 100644 --- a/rest-api/sdk/standard/client.go +++ b/rest-api/sdk/standard/client.go @@ -77,6 +77,8 @@ type APIClient struct { IPBlockAPI *IPBlockAPIService + IPXETemplateAPI *IPXETemplateAPIService + InfiniBandPartitionAPI *InfiniBandPartitionAPIService InfrastructureProviderAPI *InfrastructureProviderAPIService @@ -161,6 +163,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.HealthReportAPI = (*HealthReportAPIService)(&c.common) c.HostFirmwareConfigAPI = (*HostFirmwareConfigAPIService)(&c.common) c.IPBlockAPI = (*IPBlockAPIService)(&c.common) + c.IPXETemplateAPI = (*IPXETemplateAPIService)(&c.common) c.InfiniBandPartitionAPI = (*InfiniBandPartitionAPIService)(&c.common) c.InfrastructureProviderAPI = (*InfrastructureProviderAPIService)(&c.common) c.InstanceAPI = (*InstanceAPIService)(&c.common) diff --git a/rest-api/sdk/standard/model_ipxe_template.go b/rest-api/sdk/standard/model_ipxe_template.go new file mode 100644 index 0000000000..ef1e59460a --- /dev/null +++ b/rest-api/sdk/standard/model_ipxe_template.go @@ -0,0 +1,420 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" + "time" +) + +// checks if the IpxeTemplate type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &IpxeTemplate{} + +// IpxeTemplate An iPXE script template propagated (read-only) from nico-core +type IpxeTemplate struct { + // Stable template UUID assigned by core + Id *string `json:"id,omitempty"` + // Globally unique template name + Name *string `json:"name,omitempty"` + // Raw iPXE script content + Template *string `json:"template,omitempty"` + // Parameters that must be provided to render the template + RequiredParams []string `json:"requiredParams,omitempty"` + // Parameters reserved by the template and not user-supplied + ReservedParams []string `json:"reservedParams,omitempty"` + // Artifact names required for the template + RequiredArtifacts []string `json:"requiredArtifacts,omitempty"` + // Template visibility: Internal or Public + Visibility *string `json:"visibility,omitempty"` + Created *time.Time `json:"created,omitempty"` + Updated *time.Time `json:"updated,omitempty"` +} + +// NewIpxeTemplate instantiates a new IpxeTemplate 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 NewIpxeTemplate() *IpxeTemplate { + this := IpxeTemplate{} + return &this +} + +// NewIpxeTemplateWithDefaults instantiates a new IpxeTemplate 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 NewIpxeTemplateWithDefaults() *IpxeTemplate { + this := IpxeTemplate{} + return &this +} + +// GetId returns the Id field value if set, zero value otherwise. +func (o *IpxeTemplate) GetId() string { + if o == nil || IsNil(o.Id) { + var ret string + return ret + } + return *o.Id +} + +// GetIdOk returns a tuple with the Id field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetIdOk() (*string, bool) { + if o == nil || IsNil(o.Id) { + return nil, false + } + return o.Id, true +} + +// HasId returns a boolean if a field has been set. +func (o *IpxeTemplate) HasId() bool { + if o != nil && !IsNil(o.Id) { + return true + } + + return false +} + +// SetId gets a reference to the given string and assigns it to the Id field. +func (o *IpxeTemplate) SetId(v string) { + o.Id = &v +} + +// GetName returns the Name field value if set, zero value otherwise. +func (o *IpxeTemplate) GetName() string { + if o == nil || IsNil(o.Name) { + var ret string + return ret + } + return *o.Name +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetNameOk() (*string, bool) { + if o == nil || IsNil(o.Name) { + return nil, false + } + return o.Name, true +} + +// HasName returns a boolean if a field has been set. +func (o *IpxeTemplate) HasName() bool { + if o != nil && !IsNil(o.Name) { + return true + } + + return false +} + +// SetName gets a reference to the given string and assigns it to the Name field. +func (o *IpxeTemplate) SetName(v string) { + o.Name = &v +} + +// GetTemplate returns the Template field value if set, zero value otherwise. +func (o *IpxeTemplate) GetTemplate() string { + if o == nil || IsNil(o.Template) { + var ret string + return ret + } + return *o.Template +} + +// GetTemplateOk returns a tuple with the Template field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetTemplateOk() (*string, bool) { + if o == nil || IsNil(o.Template) { + return nil, false + } + return o.Template, true +} + +// HasTemplate returns a boolean if a field has been set. +func (o *IpxeTemplate) HasTemplate() bool { + if o != nil && !IsNil(o.Template) { + return true + } + + return false +} + +// SetTemplate gets a reference to the given string and assigns it to the Template field. +func (o *IpxeTemplate) SetTemplate(v string) { + o.Template = &v +} + +// GetRequiredParams returns the RequiredParams field value if set, zero value otherwise. +func (o *IpxeTemplate) GetRequiredParams() []string { + if o == nil || IsNil(o.RequiredParams) { + var ret []string + return ret + } + return o.RequiredParams +} + +// GetRequiredParamsOk returns a tuple with the RequiredParams field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetRequiredParamsOk() ([]string, bool) { + if o == nil || IsNil(o.RequiredParams) { + return nil, false + } + return o.RequiredParams, true +} + +// HasRequiredParams returns a boolean if a field has been set. +func (o *IpxeTemplate) HasRequiredParams() bool { + if o != nil && !IsNil(o.RequiredParams) { + return true + } + + return false +} + +// SetRequiredParams gets a reference to the given []string and assigns it to the RequiredParams field. +func (o *IpxeTemplate) SetRequiredParams(v []string) { + o.RequiredParams = v +} + +// GetReservedParams returns the ReservedParams field value if set, zero value otherwise. +func (o *IpxeTemplate) GetReservedParams() []string { + if o == nil || IsNil(o.ReservedParams) { + var ret []string + return ret + } + return o.ReservedParams +} + +// GetReservedParamsOk returns a tuple with the ReservedParams field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetReservedParamsOk() ([]string, bool) { + if o == nil || IsNil(o.ReservedParams) { + return nil, false + } + return o.ReservedParams, true +} + +// HasReservedParams returns a boolean if a field has been set. +func (o *IpxeTemplate) HasReservedParams() bool { + if o != nil && !IsNil(o.ReservedParams) { + return true + } + + return false +} + +// SetReservedParams gets a reference to the given []string and assigns it to the ReservedParams field. +func (o *IpxeTemplate) SetReservedParams(v []string) { + o.ReservedParams = v +} + +// GetRequiredArtifacts returns the RequiredArtifacts field value if set, zero value otherwise. +func (o *IpxeTemplate) GetRequiredArtifacts() []string { + if o == nil || IsNil(o.RequiredArtifacts) { + var ret []string + return ret + } + return o.RequiredArtifacts +} + +// GetRequiredArtifactsOk returns a tuple with the RequiredArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetRequiredArtifactsOk() ([]string, bool) { + if o == nil || IsNil(o.RequiredArtifacts) { + return nil, false + } + return o.RequiredArtifacts, true +} + +// HasRequiredArtifacts returns a boolean if a field has been set. +func (o *IpxeTemplate) HasRequiredArtifacts() bool { + if o != nil && !IsNil(o.RequiredArtifacts) { + return true + } + + return false +} + +// SetRequiredArtifacts gets a reference to the given []string and assigns it to the RequiredArtifacts field. +func (o *IpxeTemplate) SetRequiredArtifacts(v []string) { + o.RequiredArtifacts = v +} + +// GetVisibility returns the Visibility field value if set, zero value otherwise. +func (o *IpxeTemplate) GetVisibility() string { + if o == nil || IsNil(o.Visibility) { + var ret string + return ret + } + return *o.Visibility +} + +// GetVisibilityOk returns a tuple with the Visibility field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetVisibilityOk() (*string, bool) { + if o == nil || IsNil(o.Visibility) { + return nil, false + } + return o.Visibility, true +} + +// HasVisibility returns a boolean if a field has been set. +func (o *IpxeTemplate) HasVisibility() bool { + if o != nil && !IsNil(o.Visibility) { + return true + } + + return false +} + +// SetVisibility gets a reference to the given string and assigns it to the Visibility field. +func (o *IpxeTemplate) SetVisibility(v string) { + o.Visibility = &v +} + +// GetCreated returns the Created field value if set, zero value otherwise. +func (o *IpxeTemplate) GetCreated() time.Time { + if o == nil || IsNil(o.Created) { + var ret time.Time + return ret + } + return *o.Created +} + +// GetCreatedOk returns a tuple with the Created field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetCreatedOk() (*time.Time, bool) { + if o == nil || IsNil(o.Created) { + return nil, false + } + return o.Created, true +} + +// HasCreated returns a boolean if a field has been set. +func (o *IpxeTemplate) HasCreated() bool { + if o != nil && !IsNil(o.Created) { + return true + } + + return false +} + +// SetCreated gets a reference to the given time.Time and assigns it to the Created field. +func (o *IpxeTemplate) SetCreated(v time.Time) { + o.Created = &v +} + +// GetUpdated returns the Updated field value if set, zero value otherwise. +func (o *IpxeTemplate) GetUpdated() time.Time { + if o == nil || IsNil(o.Updated) { + var ret time.Time + return ret + } + return *o.Updated +} + +// GetUpdatedOk returns a tuple with the Updated field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetUpdatedOk() (*time.Time, bool) { + if o == nil || IsNil(o.Updated) { + return nil, false + } + return o.Updated, true +} + +// HasUpdated returns a boolean if a field has been set. +func (o *IpxeTemplate) HasUpdated() bool { + if o != nil && !IsNil(o.Updated) { + return true + } + + return false +} + +// SetUpdated gets a reference to the given time.Time and assigns it to the Updated field. +func (o *IpxeTemplate) SetUpdated(v time.Time) { + o.Updated = &v +} + +func (o IpxeTemplate) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o IpxeTemplate) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Id) { + toSerialize["id"] = o.Id + } + if !IsNil(o.Name) { + toSerialize["name"] = o.Name + } + if !IsNil(o.Template) { + toSerialize["template"] = o.Template + } + if !IsNil(o.RequiredParams) { + toSerialize["requiredParams"] = o.RequiredParams + } + if !IsNil(o.ReservedParams) { + toSerialize["reservedParams"] = o.ReservedParams + } + if !IsNil(o.RequiredArtifacts) { + toSerialize["requiredArtifacts"] = o.RequiredArtifacts + } + if !IsNil(o.Visibility) { + toSerialize["visibility"] = o.Visibility + } + if !IsNil(o.Created) { + toSerialize["created"] = o.Created + } + if !IsNil(o.Updated) { + toSerialize["updated"] = o.Updated + } + return toSerialize, nil +} + +type NullableIpxeTemplate struct { + value *IpxeTemplate + isSet bool +} + +func (v NullableIpxeTemplate) Get() *IpxeTemplate { + return v.value +} + +func (v *NullableIpxeTemplate) Set(val *IpxeTemplate) { + v.value = val + v.isSet = true +} + +func (v NullableIpxeTemplate) IsSet() bool { + return v.isSet +} + +func (v *NullableIpxeTemplate) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableIpxeTemplate(val *IpxeTemplate) *NullableIpxeTemplate { + return &NullableIpxeTemplate{value: val, isSet: true} +} + +func (v NullableIpxeTemplate) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableIpxeTemplate) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_operating_system.go b/rest-api/sdk/standard/model_operating_system.go index a150629162..75677efe07 100644 --- a/rest-api/sdk/standard/model_operating_system.go +++ b/rest-api/sdk/standard/model_operating_system.go @@ -51,6 +51,14 @@ type OperatingSystem struct { RootFsLabel NullableString `json:"rootFsLabel,omitempty"` // iPXE script or URL, only applicable for iPXE-based Operating System IpxeScript NullableString `json:"ipxeScript,omitempty"` + // ID of the iPXE template used, only present for Templated iPXE Operating System + IpxeTemplateId NullableString `json:"ipxeTemplateId,omitempty"` + // Parameters passed to the iPXE template (Templated iPXE only) + IpxeTemplateParameters []OperatingSystemIpxeParameter `json:"ipxeTemplateParameters,omitempty"` + // Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted. + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts,omitempty"` + // Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited) + Scope NullableString `json:"scope,omitempty"` // User data for the Operating System UserData NullableString `json:"userData,omitempty"` // Whether the Operating System is cloud-init based; true if there is non-empty `userData`, false otherwise. @@ -672,6 +680,156 @@ func (o *OperatingSystem) UnsetIpxeScript() { o.IpxeScript.Unset() } +// GetIpxeTemplateId returns the IpxeTemplateId field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystem) GetIpxeTemplateId() string { + if o == nil || IsNil(o.IpxeTemplateId.Get()) { + var ret string + return ret + } + return *o.IpxeTemplateId.Get() +} + +// GetIpxeTemplateIdOk returns a tuple with the IpxeTemplateId field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystem) GetIpxeTemplateIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.IpxeTemplateId.Get(), o.IpxeTemplateId.IsSet() +} + +// HasIpxeTemplateId returns a boolean if a field has been set. +func (o *OperatingSystem) HasIpxeTemplateId() bool { + if o != nil && o.IpxeTemplateId.IsSet() { + return true + } + + return false +} + +// SetIpxeTemplateId gets a reference to the given NullableString and assigns it to the IpxeTemplateId field. +func (o *OperatingSystem) SetIpxeTemplateId(v string) { + o.IpxeTemplateId.Set(&v) +} + +// SetIpxeTemplateIdNil sets the value for IpxeTemplateId to be an explicit nil +func (o *OperatingSystem) SetIpxeTemplateIdNil() { + o.IpxeTemplateId.Set(nil) +} + +// UnsetIpxeTemplateId ensures that no value is present for IpxeTemplateId, not even an explicit nil +func (o *OperatingSystem) UnsetIpxeTemplateId() { + o.IpxeTemplateId.Unset() +} + +// GetIpxeTemplateParameters returns the IpxeTemplateParameters field value if set, zero value otherwise. +func (o *OperatingSystem) GetIpxeTemplateParameters() []OperatingSystemIpxeParameter { + if o == nil || IsNil(o.IpxeTemplateParameters) { + var ret []OperatingSystemIpxeParameter + return ret + } + return o.IpxeTemplateParameters +} + +// GetIpxeTemplateParametersOk returns a tuple with the IpxeTemplateParameters field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystem) GetIpxeTemplateParametersOk() ([]OperatingSystemIpxeParameter, bool) { + if o == nil || IsNil(o.IpxeTemplateParameters) { + return nil, false + } + return o.IpxeTemplateParameters, true +} + +// HasIpxeTemplateParameters returns a boolean if a field has been set. +func (o *OperatingSystem) HasIpxeTemplateParameters() bool { + if o != nil && !IsNil(o.IpxeTemplateParameters) { + return true + } + + return false +} + +// SetIpxeTemplateParameters gets a reference to the given []OperatingSystemIpxeParameter and assigns it to the IpxeTemplateParameters field. +func (o *OperatingSystem) SetIpxeTemplateParameters(v []OperatingSystemIpxeParameter) { + o.IpxeTemplateParameters = v +} + +// GetIpxeTemplateArtifacts returns the IpxeTemplateArtifacts field value if set, zero value otherwise. +func (o *OperatingSystem) GetIpxeTemplateArtifacts() []OperatingSystemIpxeArtifact { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + var ret []OperatingSystemIpxeArtifact + return ret + } + return o.IpxeTemplateArtifacts +} + +// GetIpxeTemplateArtifactsOk returns a tuple with the IpxeTemplateArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystem) GetIpxeTemplateArtifactsOk() ([]OperatingSystemIpxeArtifact, bool) { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + return nil, false + } + return o.IpxeTemplateArtifacts, true +} + +// HasIpxeTemplateArtifacts returns a boolean if a field has been set. +func (o *OperatingSystem) HasIpxeTemplateArtifacts() bool { + if o != nil && !IsNil(o.IpxeTemplateArtifacts) { + return true + } + + return false +} + +// SetIpxeTemplateArtifacts gets a reference to the given []OperatingSystemIpxeArtifact and assigns it to the IpxeTemplateArtifacts field. +func (o *OperatingSystem) SetIpxeTemplateArtifacts(v []OperatingSystemIpxeArtifact) { + o.IpxeTemplateArtifacts = v +} + +// GetScope returns the Scope field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystem) GetScope() string { + if o == nil || IsNil(o.Scope.Get()) { + var ret string + return ret + } + return *o.Scope.Get() +} + +// GetScopeOk returns a tuple with the Scope field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystem) GetScopeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Scope.Get(), o.Scope.IsSet() +} + +// HasScope returns a boolean if a field has been set. +func (o *OperatingSystem) HasScope() bool { + if o != nil && o.Scope.IsSet() { + return true + } + + return false +} + +// SetScope gets a reference to the given NullableString and assigns it to the Scope field. +func (o *OperatingSystem) SetScope(v string) { + o.Scope.Set(&v) +} + +// SetScopeNil sets the value for Scope to be an explicit nil +func (o *OperatingSystem) SetScopeNil() { + o.Scope.Set(nil) +} + +// UnsetScope ensures that no value is present for Scope, not even an explicit nil +func (o *OperatingSystem) UnsetScope() { + o.Scope.Unset() +} + // GetUserData returns the UserData field value if set, zero value otherwise (both if not set or set to explicit null). func (o *OperatingSystem) GetUserData() string { if o == nil || IsNil(o.UserData.Get()) { @@ -1098,6 +1256,18 @@ func (o OperatingSystem) ToMap() (map[string]interface{}, error) { if o.IpxeScript.IsSet() { toSerialize["ipxeScript"] = o.IpxeScript.Get() } + if o.IpxeTemplateId.IsSet() { + toSerialize["ipxeTemplateId"] = o.IpxeTemplateId.Get() + } + if !IsNil(o.IpxeTemplateParameters) { + toSerialize["ipxeTemplateParameters"] = o.IpxeTemplateParameters + } + if !IsNil(o.IpxeTemplateArtifacts) { + toSerialize["ipxeTemplateArtifacts"] = o.IpxeTemplateArtifacts + } + if o.Scope.IsSet() { + toSerialize["scope"] = o.Scope.Get() + } if o.UserData.IsSet() { toSerialize["userData"] = o.UserData.Get() } diff --git a/rest-api/sdk/standard/model_operating_system_create_request.go b/rest-api/sdk/standard/model_operating_system_create_request.go index 6f31207c42..8cc0bda682 100644 --- a/rest-api/sdk/standard/model_operating_system_create_request.go +++ b/rest-api/sdk/standard/model_operating_system_create_request.go @@ -61,6 +61,14 @@ type OperatingSystemCreateRequest struct { IsCloudInit *bool `json:"isCloudInit,omitempty"` // Indicates if the user data can be overridden at Instance creation time AllowOverride *bool `json:"allowOverride,omitempty"` + // ID of the iPXE template to use; identifies a Templated iPXE Operating System. Mutually exclusive with ipxeScript and imageUrl. + IpxeTemplateId NullableString `json:"ipxeTemplateId,omitempty"` + // Parameters passed to the iPXE template (Templated iPXE only). + IpxeTemplateParameters []OperatingSystemIpxeParameter `json:"ipxeTemplateParameters,omitempty"` + // Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only). + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts,omitempty"` + // Synchronization scope for iPXE-based Operating Systems. Required for Templated iPXE (Global or Limited; Local is created only in nico-core). + Scope NullableString `json:"scope,omitempty"` } type _OperatingSystemCreateRequest OperatingSystemCreateRequest @@ -771,6 +779,156 @@ func (o *OperatingSystemCreateRequest) SetAllowOverride(v bool) { o.AllowOverride = &v } +// GetIpxeTemplateId returns the IpxeTemplateId field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemCreateRequest) GetIpxeTemplateId() string { + if o == nil || IsNil(o.IpxeTemplateId.Get()) { + var ret string + return ret + } + return *o.IpxeTemplateId.Get() +} + +// GetIpxeTemplateIdOk returns a tuple with the IpxeTemplateId field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemCreateRequest) GetIpxeTemplateIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.IpxeTemplateId.Get(), o.IpxeTemplateId.IsSet() +} + +// HasIpxeTemplateId returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasIpxeTemplateId() bool { + if o != nil && o.IpxeTemplateId.IsSet() { + return true + } + + return false +} + +// SetIpxeTemplateId gets a reference to the given NullableString and assigns it to the IpxeTemplateId field. +func (o *OperatingSystemCreateRequest) SetIpxeTemplateId(v string) { + o.IpxeTemplateId.Set(&v) +} + +// SetIpxeTemplateIdNil sets the value for IpxeTemplateId to be an explicit nil +func (o *OperatingSystemCreateRequest) SetIpxeTemplateIdNil() { + o.IpxeTemplateId.Set(nil) +} + +// UnsetIpxeTemplateId ensures that no value is present for IpxeTemplateId, not even an explicit nil +func (o *OperatingSystemCreateRequest) UnsetIpxeTemplateId() { + o.IpxeTemplateId.Unset() +} + +// GetIpxeTemplateParameters returns the IpxeTemplateParameters field value if set, zero value otherwise. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateParameters() []OperatingSystemIpxeParameter { + if o == nil || IsNil(o.IpxeTemplateParameters) { + var ret []OperatingSystemIpxeParameter + return ret + } + return o.IpxeTemplateParameters +} + +// GetIpxeTemplateParametersOk returns a tuple with the IpxeTemplateParameters field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateParametersOk() ([]OperatingSystemIpxeParameter, bool) { + if o == nil || IsNil(o.IpxeTemplateParameters) { + return nil, false + } + return o.IpxeTemplateParameters, true +} + +// HasIpxeTemplateParameters returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasIpxeTemplateParameters() bool { + if o != nil && !IsNil(o.IpxeTemplateParameters) { + return true + } + + return false +} + +// SetIpxeTemplateParameters gets a reference to the given []OperatingSystemIpxeParameter and assigns it to the IpxeTemplateParameters field. +func (o *OperatingSystemCreateRequest) SetIpxeTemplateParameters(v []OperatingSystemIpxeParameter) { + o.IpxeTemplateParameters = v +} + +// GetIpxeTemplateArtifacts returns the IpxeTemplateArtifacts field value if set, zero value otherwise. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateArtifacts() []OperatingSystemIpxeArtifact { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + var ret []OperatingSystemIpxeArtifact + return ret + } + return o.IpxeTemplateArtifacts +} + +// GetIpxeTemplateArtifactsOk returns a tuple with the IpxeTemplateArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateArtifactsOk() ([]OperatingSystemIpxeArtifact, bool) { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + return nil, false + } + return o.IpxeTemplateArtifacts, true +} + +// HasIpxeTemplateArtifacts returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasIpxeTemplateArtifacts() bool { + if o != nil && !IsNil(o.IpxeTemplateArtifacts) { + return true + } + + return false +} + +// SetIpxeTemplateArtifacts gets a reference to the given []OperatingSystemIpxeArtifact and assigns it to the IpxeTemplateArtifacts field. +func (o *OperatingSystemCreateRequest) SetIpxeTemplateArtifacts(v []OperatingSystemIpxeArtifact) { + o.IpxeTemplateArtifacts = v +} + +// GetScope returns the Scope field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemCreateRequest) GetScope() string { + if o == nil || IsNil(o.Scope.Get()) { + var ret string + return ret + } + return *o.Scope.Get() +} + +// GetScopeOk returns a tuple with the Scope field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemCreateRequest) GetScopeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Scope.Get(), o.Scope.IsSet() +} + +// HasScope returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasScope() bool { + if o != nil && o.Scope.IsSet() { + return true + } + + return false +} + +// SetScope gets a reference to the given NullableString and assigns it to the Scope field. +func (o *OperatingSystemCreateRequest) SetScope(v string) { + o.Scope.Set(&v) +} + +// SetScopeNil sets the value for Scope to be an explicit nil +func (o *OperatingSystemCreateRequest) SetScopeNil() { + o.Scope.Set(nil) +} + +// UnsetScope ensures that no value is present for Scope, not even an explicit nil +func (o *OperatingSystemCreateRequest) UnsetScope() { + o.Scope.Unset() +} + func (o OperatingSystemCreateRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -830,6 +988,18 @@ func (o OperatingSystemCreateRequest) ToMap() (map[string]interface{}, error) { if !IsNil(o.AllowOverride) { toSerialize["allowOverride"] = o.AllowOverride } + if o.IpxeTemplateId.IsSet() { + toSerialize["ipxeTemplateId"] = o.IpxeTemplateId.Get() + } + if !IsNil(o.IpxeTemplateParameters) { + toSerialize["ipxeTemplateParameters"] = o.IpxeTemplateParameters + } + if !IsNil(o.IpxeTemplateArtifacts) { + toSerialize["ipxeTemplateArtifacts"] = o.IpxeTemplateArtifacts + } + if o.Scope.IsSet() { + toSerialize["scope"] = o.Scope.Get() + } return toSerialize, nil } diff --git a/rest-api/sdk/standard/model_operating_system_ipxe_artifact.go b/rest-api/sdk/standard/model_operating_system_ipxe_artifact.go new file mode 100644 index 0000000000..66f3bba01f --- /dev/null +++ b/rest-api/sdk/standard/model_operating_system_ipxe_artifact.go @@ -0,0 +1,343 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the OperatingSystemIpxeArtifact type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OperatingSystemIpxeArtifact{} + +// OperatingSystemIpxeArtifact An artifact (kernel, initrd, ISO, ...) referenced by an iPXE OS definition +type OperatingSystemIpxeArtifact struct { + // Artifact name + Name *string `json:"name,omitempty"` + // Original URL for the artifact + Url *string `json:"url,omitempty"` + // Optional SHA256 checksum + Sha NullableString `json:"sha,omitempty"` + // Optional auth type: Basic or Bearer + AuthType NullableString `json:"authType,omitempty"` + // Optional auth token. Redacted in API responses. + AuthToken NullableString `json:"authToken,omitempty"` + // How to handle caching for this artifact + CacheStrategy *string `json:"cacheStrategy,omitempty"` +} + +// NewOperatingSystemIpxeArtifact instantiates a new OperatingSystemIpxeArtifact 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 NewOperatingSystemIpxeArtifact() *OperatingSystemIpxeArtifact { + this := OperatingSystemIpxeArtifact{} + return &this +} + +// NewOperatingSystemIpxeArtifactWithDefaults instantiates a new OperatingSystemIpxeArtifact 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 NewOperatingSystemIpxeArtifactWithDefaults() *OperatingSystemIpxeArtifact { + this := OperatingSystemIpxeArtifact{} + return &this +} + +// GetName returns the Name field value if set, zero value otherwise. +func (o *OperatingSystemIpxeArtifact) GetName() string { + if o == nil || IsNil(o.Name) { + var ret string + return ret + } + return *o.Name +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeArtifact) GetNameOk() (*string, bool) { + if o == nil || IsNil(o.Name) { + return nil, false + } + return o.Name, true +} + +// HasName returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasName() bool { + if o != nil && !IsNil(o.Name) { + return true + } + + return false +} + +// SetName gets a reference to the given string and assigns it to the Name field. +func (o *OperatingSystemIpxeArtifact) SetName(v string) { + o.Name = &v +} + +// GetUrl returns the Url field value if set, zero value otherwise. +func (o *OperatingSystemIpxeArtifact) GetUrl() string { + if o == nil || IsNil(o.Url) { + var ret string + return ret + } + return *o.Url +} + +// GetUrlOk returns a tuple with the Url field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeArtifact) GetUrlOk() (*string, bool) { + if o == nil || IsNil(o.Url) { + return nil, false + } + return o.Url, true +} + +// HasUrl returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasUrl() bool { + if o != nil && !IsNil(o.Url) { + return true + } + + return false +} + +// SetUrl gets a reference to the given string and assigns it to the Url field. +func (o *OperatingSystemIpxeArtifact) SetUrl(v string) { + o.Url = &v +} + +// GetSha returns the Sha field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemIpxeArtifact) GetSha() string { + if o == nil || IsNil(o.Sha.Get()) { + var ret string + return ret + } + return *o.Sha.Get() +} + +// GetShaOk returns a tuple with the Sha field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemIpxeArtifact) GetShaOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Sha.Get(), o.Sha.IsSet() +} + +// HasSha returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasSha() bool { + if o != nil && o.Sha.IsSet() { + return true + } + + return false +} + +// SetSha gets a reference to the given NullableString and assigns it to the Sha field. +func (o *OperatingSystemIpxeArtifact) SetSha(v string) { + o.Sha.Set(&v) +} + +// SetShaNil sets the value for Sha to be an explicit nil +func (o *OperatingSystemIpxeArtifact) SetShaNil() { + o.Sha.Set(nil) +} + +// UnsetSha ensures that no value is present for Sha, not even an explicit nil +func (o *OperatingSystemIpxeArtifact) UnsetSha() { + o.Sha.Unset() +} + +// GetAuthType returns the AuthType field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemIpxeArtifact) GetAuthType() string { + if o == nil || IsNil(o.AuthType.Get()) { + var ret string + return ret + } + return *o.AuthType.Get() +} + +// GetAuthTypeOk returns a tuple with the AuthType field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemIpxeArtifact) GetAuthTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.AuthType.Get(), o.AuthType.IsSet() +} + +// HasAuthType returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasAuthType() bool { + if o != nil && o.AuthType.IsSet() { + return true + } + + return false +} + +// SetAuthType gets a reference to the given NullableString and assigns it to the AuthType field. +func (o *OperatingSystemIpxeArtifact) SetAuthType(v string) { + o.AuthType.Set(&v) +} + +// SetAuthTypeNil sets the value for AuthType to be an explicit nil +func (o *OperatingSystemIpxeArtifact) SetAuthTypeNil() { + o.AuthType.Set(nil) +} + +// UnsetAuthType ensures that no value is present for AuthType, not even an explicit nil +func (o *OperatingSystemIpxeArtifact) UnsetAuthType() { + o.AuthType.Unset() +} + +// GetAuthToken returns the AuthToken field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemIpxeArtifact) GetAuthToken() string { + if o == nil || IsNil(o.AuthToken.Get()) { + var ret string + return ret + } + return *o.AuthToken.Get() +} + +// GetAuthTokenOk returns a tuple with the AuthToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemIpxeArtifact) GetAuthTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.AuthToken.Get(), o.AuthToken.IsSet() +} + +// HasAuthToken returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasAuthToken() bool { + if o != nil && o.AuthToken.IsSet() { + return true + } + + return false +} + +// SetAuthToken gets a reference to the given NullableString and assigns it to the AuthToken field. +func (o *OperatingSystemIpxeArtifact) SetAuthToken(v string) { + o.AuthToken.Set(&v) +} + +// SetAuthTokenNil sets the value for AuthToken to be an explicit nil +func (o *OperatingSystemIpxeArtifact) SetAuthTokenNil() { + o.AuthToken.Set(nil) +} + +// UnsetAuthToken ensures that no value is present for AuthToken, not even an explicit nil +func (o *OperatingSystemIpxeArtifact) UnsetAuthToken() { + o.AuthToken.Unset() +} + +// GetCacheStrategy returns the CacheStrategy field value if set, zero value otherwise. +func (o *OperatingSystemIpxeArtifact) GetCacheStrategy() string { + if o == nil || IsNil(o.CacheStrategy) { + var ret string + return ret + } + return *o.CacheStrategy +} + +// GetCacheStrategyOk returns a tuple with the CacheStrategy field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeArtifact) GetCacheStrategyOk() (*string, bool) { + if o == nil || IsNil(o.CacheStrategy) { + return nil, false + } + return o.CacheStrategy, true +} + +// HasCacheStrategy returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasCacheStrategy() bool { + if o != nil && !IsNil(o.CacheStrategy) { + return true + } + + return false +} + +// SetCacheStrategy gets a reference to the given string and assigns it to the CacheStrategy field. +func (o *OperatingSystemIpxeArtifact) SetCacheStrategy(v string) { + o.CacheStrategy = &v +} + +func (o OperatingSystemIpxeArtifact) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OperatingSystemIpxeArtifact) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Name) { + toSerialize["name"] = o.Name + } + if !IsNil(o.Url) { + toSerialize["url"] = o.Url + } + if o.Sha.IsSet() { + toSerialize["sha"] = o.Sha.Get() + } + if o.AuthType.IsSet() { + toSerialize["authType"] = o.AuthType.Get() + } + if o.AuthToken.IsSet() { + toSerialize["authToken"] = o.AuthToken.Get() + } + if !IsNil(o.CacheStrategy) { + toSerialize["cacheStrategy"] = o.CacheStrategy + } + return toSerialize, nil +} + +type NullableOperatingSystemIpxeArtifact struct { + value *OperatingSystemIpxeArtifact + isSet bool +} + +func (v NullableOperatingSystemIpxeArtifact) Get() *OperatingSystemIpxeArtifact { + return v.value +} + +func (v *NullableOperatingSystemIpxeArtifact) Set(val *OperatingSystemIpxeArtifact) { + v.value = val + v.isSet = true +} + +func (v NullableOperatingSystemIpxeArtifact) IsSet() bool { + return v.isSet +} + +func (v *NullableOperatingSystemIpxeArtifact) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOperatingSystemIpxeArtifact(val *OperatingSystemIpxeArtifact) *NullableOperatingSystemIpxeArtifact { + return &NullableOperatingSystemIpxeArtifact{value: val, isSet: true} +} + +func (v NullableOperatingSystemIpxeArtifact) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOperatingSystemIpxeArtifact) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_operating_system_ipxe_parameter.go b/rest-api/sdk/standard/model_operating_system_ipxe_parameter.go new file mode 100644 index 0000000000..28940fb7ca --- /dev/null +++ b/rest-api/sdk/standard/model_operating_system_ipxe_parameter.go @@ -0,0 +1,162 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the OperatingSystemIpxeParameter type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OperatingSystemIpxeParameter{} + +// OperatingSystemIpxeParameter A name/value parameter passed to an iPXE template +type OperatingSystemIpxeParameter struct { + // Parameter name (used as a variable in the template) + Name *string `json:"name,omitempty"` + // Parameter value + Value *string `json:"value,omitempty"` +} + +// NewOperatingSystemIpxeParameter instantiates a new OperatingSystemIpxeParameter 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 NewOperatingSystemIpxeParameter() *OperatingSystemIpxeParameter { + this := OperatingSystemIpxeParameter{} + return &this +} + +// NewOperatingSystemIpxeParameterWithDefaults instantiates a new OperatingSystemIpxeParameter 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 NewOperatingSystemIpxeParameterWithDefaults() *OperatingSystemIpxeParameter { + this := OperatingSystemIpxeParameter{} + return &this +} + +// GetName returns the Name field value if set, zero value otherwise. +func (o *OperatingSystemIpxeParameter) GetName() string { + if o == nil || IsNil(o.Name) { + var ret string + return ret + } + return *o.Name +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeParameter) GetNameOk() (*string, bool) { + if o == nil || IsNil(o.Name) { + return nil, false + } + return o.Name, true +} + +// HasName returns a boolean if a field has been set. +func (o *OperatingSystemIpxeParameter) HasName() bool { + if o != nil && !IsNil(o.Name) { + return true + } + + return false +} + +// SetName gets a reference to the given string and assigns it to the Name field. +func (o *OperatingSystemIpxeParameter) SetName(v string) { + o.Name = &v +} + +// GetValue returns the Value field value if set, zero value otherwise. +func (o *OperatingSystemIpxeParameter) GetValue() string { + if o == nil || IsNil(o.Value) { + var ret string + return ret + } + return *o.Value +} + +// GetValueOk returns a tuple with the Value field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeParameter) GetValueOk() (*string, bool) { + if o == nil || IsNil(o.Value) { + return nil, false + } + return o.Value, true +} + +// HasValue returns a boolean if a field has been set. +func (o *OperatingSystemIpxeParameter) HasValue() bool { + if o != nil && !IsNil(o.Value) { + return true + } + + return false +} + +// SetValue gets a reference to the given string and assigns it to the Value field. +func (o *OperatingSystemIpxeParameter) SetValue(v string) { + o.Value = &v +} + +func (o OperatingSystemIpxeParameter) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OperatingSystemIpxeParameter) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Name) { + toSerialize["name"] = o.Name + } + if !IsNil(o.Value) { + toSerialize["value"] = o.Value + } + return toSerialize, nil +} + +type NullableOperatingSystemIpxeParameter struct { + value *OperatingSystemIpxeParameter + isSet bool +} + +func (v NullableOperatingSystemIpxeParameter) Get() *OperatingSystemIpxeParameter { + return v.value +} + +func (v *NullableOperatingSystemIpxeParameter) Set(val *OperatingSystemIpxeParameter) { + v.value = val + v.isSet = true +} + +func (v NullableOperatingSystemIpxeParameter) IsSet() bool { + return v.isSet +} + +func (v *NullableOperatingSystemIpxeParameter) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOperatingSystemIpxeParameter(val *OperatingSystemIpxeParameter) *NullableOperatingSystemIpxeParameter { + return &NullableOperatingSystemIpxeParameter{value: val, isSet: true} +} + +func (v NullableOperatingSystemIpxeParameter) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOperatingSystemIpxeParameter) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_operating_system_update_request.go b/rest-api/sdk/standard/model_operating_system_update_request.go index cfe12b8043..7ab7b5da40 100644 --- a/rest-api/sdk/standard/model_operating_system_update_request.go +++ b/rest-api/sdk/standard/model_operating_system_update_request.go @@ -55,6 +55,14 @@ type OperatingSystemUpdateRequest struct { IsActive NullableBool `json:"isActive,omitempty"` // Optional deactivation note if OS is inactive DeactivationNote NullableString `json:"deactivationNote,omitempty"` + // ID of the iPXE template to use (Templated iPXE only). Mutually exclusive with ipxeScript and imageUrl. + IpxeTemplateId NullableString `json:"ipxeTemplateId,omitempty"` + // Parameters passed to the iPXE template (Templated iPXE only). + IpxeTemplateParameters []OperatingSystemIpxeParameter `json:"ipxeTemplateParameters,omitempty"` + // Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only). + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts,omitempty"` + // Scope is immutable after creation; if provided the request is rejected. + Scope NullableString `json:"scope,omitempty"` } // NewOperatingSystemUpdateRequest instantiates a new OperatingSystemUpdateRequest object @@ -765,6 +773,156 @@ func (o *OperatingSystemUpdateRequest) UnsetDeactivationNote() { o.DeactivationNote.Unset() } +// GetIpxeTemplateId returns the IpxeTemplateId field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateId() string { + if o == nil || IsNil(o.IpxeTemplateId.Get()) { + var ret string + return ret + } + return *o.IpxeTemplateId.Get() +} + +// GetIpxeTemplateIdOk returns a tuple with the IpxeTemplateId field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.IpxeTemplateId.Get(), o.IpxeTemplateId.IsSet() +} + +// HasIpxeTemplateId returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasIpxeTemplateId() bool { + if o != nil && o.IpxeTemplateId.IsSet() { + return true + } + + return false +} + +// SetIpxeTemplateId gets a reference to the given NullableString and assigns it to the IpxeTemplateId field. +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateId(v string) { + o.IpxeTemplateId.Set(&v) +} + +// SetIpxeTemplateIdNil sets the value for IpxeTemplateId to be an explicit nil +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateIdNil() { + o.IpxeTemplateId.Set(nil) +} + +// UnsetIpxeTemplateId ensures that no value is present for IpxeTemplateId, not even an explicit nil +func (o *OperatingSystemUpdateRequest) UnsetIpxeTemplateId() { + o.IpxeTemplateId.Unset() +} + +// GetIpxeTemplateParameters returns the IpxeTemplateParameters field value if set, zero value otherwise. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateParameters() []OperatingSystemIpxeParameter { + if o == nil || IsNil(o.IpxeTemplateParameters) { + var ret []OperatingSystemIpxeParameter + return ret + } + return o.IpxeTemplateParameters +} + +// GetIpxeTemplateParametersOk returns a tuple with the IpxeTemplateParameters field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateParametersOk() ([]OperatingSystemIpxeParameter, bool) { + if o == nil || IsNil(o.IpxeTemplateParameters) { + return nil, false + } + return o.IpxeTemplateParameters, true +} + +// HasIpxeTemplateParameters returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasIpxeTemplateParameters() bool { + if o != nil && !IsNil(o.IpxeTemplateParameters) { + return true + } + + return false +} + +// SetIpxeTemplateParameters gets a reference to the given []OperatingSystemIpxeParameter and assigns it to the IpxeTemplateParameters field. +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateParameters(v []OperatingSystemIpxeParameter) { + o.IpxeTemplateParameters = v +} + +// GetIpxeTemplateArtifacts returns the IpxeTemplateArtifacts field value if set, zero value otherwise. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateArtifacts() []OperatingSystemIpxeArtifact { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + var ret []OperatingSystemIpxeArtifact + return ret + } + return o.IpxeTemplateArtifacts +} + +// GetIpxeTemplateArtifactsOk returns a tuple with the IpxeTemplateArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateArtifactsOk() ([]OperatingSystemIpxeArtifact, bool) { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + return nil, false + } + return o.IpxeTemplateArtifacts, true +} + +// HasIpxeTemplateArtifacts returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasIpxeTemplateArtifacts() bool { + if o != nil && !IsNil(o.IpxeTemplateArtifacts) { + return true + } + + return false +} + +// SetIpxeTemplateArtifacts gets a reference to the given []OperatingSystemIpxeArtifact and assigns it to the IpxeTemplateArtifacts field. +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateArtifacts(v []OperatingSystemIpxeArtifact) { + o.IpxeTemplateArtifacts = v +} + +// GetScope returns the Scope field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemUpdateRequest) GetScope() string { + if o == nil || IsNil(o.Scope.Get()) { + var ret string + return ret + } + return *o.Scope.Get() +} + +// GetScopeOk returns a tuple with the Scope field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemUpdateRequest) GetScopeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Scope.Get(), o.Scope.IsSet() +} + +// HasScope returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasScope() bool { + if o != nil && o.Scope.IsSet() { + return true + } + + return false +} + +// SetScope gets a reference to the given NullableString and assigns it to the Scope field. +func (o *OperatingSystemUpdateRequest) SetScope(v string) { + o.Scope.Set(&v) +} + +// SetScopeNil sets the value for Scope to be an explicit nil +func (o *OperatingSystemUpdateRequest) SetScopeNil() { + o.Scope.Set(nil) +} + +// UnsetScope ensures that no value is present for Scope, not even an explicit nil +func (o *OperatingSystemUpdateRequest) UnsetScope() { + o.Scope.Unset() +} + func (o OperatingSystemUpdateRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -823,6 +981,18 @@ func (o OperatingSystemUpdateRequest) ToMap() (map[string]interface{}, error) { if o.DeactivationNote.IsSet() { toSerialize["deactivationNote"] = o.DeactivationNote.Get() } + if o.IpxeTemplateId.IsSet() { + toSerialize["ipxeTemplateId"] = o.IpxeTemplateId.Get() + } + if !IsNil(o.IpxeTemplateParameters) { + toSerialize["ipxeTemplateParameters"] = o.IpxeTemplateParameters + } + if !IsNil(o.IpxeTemplateArtifacts) { + toSerialize["ipxeTemplateArtifacts"] = o.IpxeTemplateArtifacts + } + if o.Scope.IsSet() { + toSerialize["scope"] = o.Scope.Get() + } return toSerialize, nil } From 2bc218cfc526d778e3db4229b2c86b4be83362d9 Mon Sep 17 00:00:00 2001 From: Patrice Breton Date: Wed, 15 Jul 2026 15:12:44 -0700 Subject: [PATCH 02/18] address CodeRabbit comments Signed-off-by: Patrice Breton --- rest-api/api/pkg/api/handler/instance.go | 46 ++++ .../api/handler/instance_templated_os_test.go | 179 ++++++++++++ rest-api/api/pkg/api/handler/instancebatch.go | 3 + rest-api/api/pkg/api/handler/ipxetemplate.go | 27 +- .../api/pkg/api/handler/operatingsystem.go | 98 +++++-- .../operatingsystem_templated_proxy_test.go | 3 +- rest-api/api/pkg/api/model/operatingsystem.go | 3 + rest-api/docs/index.html | 254 ++++++++++++++++-- rest-api/openapi/spec.yaml | 31 ++- rest-api/sdk/standard/api_ipxe_template.go | 12 +- rest-api/sdk/standard/api_operating_system.go | 6 +- rest-api/sdk/standard/model_ipxe_template.go | 5 +- .../model_operating_system_ipxe_artifact.go | 5 +- .../model_operating_system_ipxe_parameter.go | 5 +- 14 files changed, 603 insertions(+), 74 deletions(-) create mode 100644 rest-api/api/pkg/api/handler/instance_templated_os_test.go diff --git a/rest-api/api/pkg/api/handler/instance.go b/rest-api/api/pkg/api/handler/instance.go index 8ca5049637..57dc874965 100644 --- a/rest-api/api/pkg/api/handler/instance.go +++ b/rest-api/api/pkg/api/handler/instance.go @@ -89,6 +89,46 @@ func NewCreateInstanceHandler(dbSession *cdb.Session, tc temporalClient.Client, } } +// validateTemplatedIpxeOsForSite guards the Templated iPXE Operating System +// selection paths (Instance create / update / batch-create) before the OS ID is +// sent to Core. Caller authorization and tenant/OS ownership are already enforced +// by the handlers (ValidateOrgMembership / ValidateUserRoles) and the per-request +// ownership check, so this enforces the site-availability contract specific to +// templated OSes: the OS definition must be synchronized to the Instance's Site +// (a Synced OperatingSystemSiteAssociation) so the Site can render the template +// at provisioning time. +// +// This is intentionally scope-agnostic. osScope constrains OS *creation* and +// definition sync direction, not Instance selection: a Local OS (created in +// nico-core) is a legitimate, usable definition once it is present at its Site, +// and Global/Limited OSes are usable once their rest -> core push has completed. +// In every case a Synced association is the signal that the definition is +// actually available at the Site, so we gate on that alone. +func validateTemplatedIpxeOsForSite(ctx context.Context, dbSession *cdb.Session, logger *zerolog.Logger, os *cdbm.OperatingSystem, siteID uuid.UUID) *cutil.APIError { + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) + _, ossaCount, err := ossaDAO.GetAll( + ctx, + nil, + cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{os.ID}, + SiteIDs: []uuid.UUID{siteID}, + Statuses: []string{cdbm.OperatingSystemSiteAssociationStatusSynced}, + }, + cdbp.PageInput{Limit: cutil.GetPtr(1)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Str("operatingSystemId", os.ID.String()).Msg("error retrieving OperatingSystemSiteAssociations for Templated iPXE OS") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve OperatingSystem site associations, DB error", nil) + } + if ossaCount == 0 { + logger.Warn().Str("operatingSystemId", os.ID.String()).Str("siteId", siteID.String()).Msg("Templated iPXE Operating System is not synchronized to the Instance's Site") + return cutil.NewAPIError(http.StatusBadRequest, "Templated iPXE Operating System specified in request is not synchronized to the Instance's Site", nil) + } + + return nil +} + // Returns either a default OS or an existing instance OS config. // apiRequest will be mutated for use in create. // osConfig will hold the struct/data for use with Temporal/NICo calls. @@ -214,6 +254,9 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte UserData: apiRequest.UserData, }, osID, nil } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + if apiErr := validateTemplatedIpxeOsForSite(ctx, cih.dbSession, logger, os, site.ID); apiErr != nil { + return nil, nil, apiErr + } return &corev1.InstanceOperatingSystemConfig{ RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, @@ -2110,6 +2153,9 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte UserData: userData, }, osID, nil } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + if apiErr := validateTemplatedIpxeOsForSite(ctx, uih.dbSession, logger, os, site.ID); apiErr != nil { + return nil, nil, apiErr + } return &corev1.InstanceOperatingSystemConfig{ RunProvisioningInstructionsOnEveryBoot: alwaysBootWithCustomIpxe, PhoneHomeEnabled: phoneHomeEnabled, diff --git a/rest-api/api/pkg/api/handler/instance_templated_os_test.go b/rest-api/api/pkg/api/handler/instance_templated_os_test.go new file mode 100644 index 0000000000..4ef79567c3 --- /dev/null +++ b/rest-api/api/pkg/api/handler/instance_templated_os_test.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + 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/labstack/echo/v4" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTemplatedOsEchoContext returns a bare echo.Context whose request carries a +// background context, which is all the buildInstance*OsConfig helpers require. +func newTemplatedOsEchoContext(t *testing.T) echo.Context { + t.Helper() + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + req = req.WithContext(context.Background()) + return e.NewContext(req, httptest.NewRecorder()) +} + +// assertTemplatedOsConfig asserts that a built InstanceOperatingSystemConfig +// references the Operating System by ID (the Templated iPXE variant) rather than +// carrying an inline iPXE script or an OS image reference. +func assertTemplatedOsConfig(t *testing.T, osConfig *corev1.InstanceOperatingSystemConfig, osID uuid.UUID) { + t.Helper() + require.NotNil(t, osConfig) + variant, ok := osConfig.Variant.(*corev1.InstanceOperatingSystemConfig_OperatingSystemId) + require.Truef(t, ok, "expected OperatingSystemId variant for Templated iPXE OS, got %T", osConfig.Variant) + require.NotNil(t, variant.OperatingSystemId) + assert.Equal(t, osID.String(), variant.OperatingSystemId.Value) +} + +// TestBuildInstanceOsConfig_TemplatedIPXE verifies that a Templated iPXE +// Operating System is translated into an InstanceOperatingSystemConfig that +// references the OS by ID (corev1.InstanceOperatingSystemConfig_OperatingSystemId) +// across the create, update and batch-create instance paths, and that the shared +// templated-OS site validator rejects OSes that are not usable at the Site. +// +// Unlike raw iPXE (inline script) and Image (OS image ID) types, a Templated +// iPXE OS is rendered on the Site from its template, so only the OS ID is +// propagated to Core, and only once the definition is synchronized to that Site. +func TestBuildInstanceOsConfig_TemplatedIPXE(t *testing.T) { + ctx := context.Background() + dbSession := testMachineInitDB(t) + defer dbSession.Close() + + common.TestSetupSchema(t, dbSession) + + cfg := common.GetTestConfig() + logger := zerolog.Nop() + + org := "tmpl-os-instance-org" + user := testInstanceBuildUser(t, dbSession, uuid.NewString(), org, []string{"tenant_admin"}) + tenant := testInstanceBuildTenant(t, dbSession, "tmpl-os-instance-tenant", org, user) + + ip := testMachineBuildInfrastructureProvider(t, dbSession, "tmpl-os-instance-ip-org", "tmpl-os-instance-provider") + site := testMachineBuildSite(t, dbSession, ip, "tmpl-os-instance-site", cdbm.SiteStatusRegistered) + + // buildOS builds a tenant-owned Templated iPXE OS with the given scope. + buildOS := func(name, scope string) *cdbm.OperatingSystem { + os := testInstanceBuildOperatingSystem(t, dbSession, name, tenant, + cdbm.OperatingSystemTypeTemplatedIPXE, false, nil, false, cdbm.OperatingSystemStatusReady, user) + os.IpxeOsScope = cutil.GetPtr(scope) + _, err := dbSession.DB.NewUpdate().Model(os).Column("ipxe_os_scope").WherePK().Exec(ctx) + require.NoError(t, err) + return os + } + + // A Global-scope OS synchronized (Synced association) to the Site: the happy path. + osSynced := buildOS("tmpl-os-instance-os-synced", cdbm.OperatingSystemScopeGlobal) + testInstanceBuildOperatingSystemSiteAssociation(t, dbSession, site.ID, osSynced.ID) + + t.Run("create", func(t *testing.T) { + ec := newTemplatedOsEchoContext(t) + h := CreateInstanceHandler{dbSession: dbSession, cfg: cfg} + apiReq := &model.APIInstanceCreateRequest{ + TenantID: tenant.ID.String(), + OperatingSystemID: cutil.GetPtr(osSynced.ID.String()), + } + + osConfig, osID, apiErr := h.buildInstanceCreateRequestOsConfig(ec, &logger, apiReq, site) + require.Nil(t, apiErr) + require.NotNil(t, osID) + assert.Equal(t, osSynced.ID, *osID) + assertTemplatedOsConfig(t, osConfig, osSynced.ID) + }) + + t.Run("update", func(t *testing.T) { + ec := newTemplatedOsEchoContext(t) + h := UpdateInstanceHandler{dbSession: dbSession, cfg: cfg} + // The instance is passed directly (not loaded from the DB) and only needs + // its Tenant populated for the ownership check. + instance := &cdbm.Instance{ + ID: uuid.New(), + TenantID: tenant.ID, + Tenant: tenant, + } + apiReq := &model.APIInstanceUpdateRequest{ + OperatingSystemID: cutil.GetPtr(osSynced.ID.String()), + } + + osConfig, osID, apiErr := h.buildInstanceUpdateRequestOsConfig(ec, &logger, apiReq, instance, site) + require.Nil(t, apiErr) + require.NotNil(t, osID) + assert.Equal(t, osSynced.ID, *osID) + assertTemplatedOsConfig(t, osConfig, osSynced.ID) + }) + + t.Run("batch create", func(t *testing.T) { + ec := newTemplatedOsEchoContext(t) + h := BatchCreateInstanceHandler{dbSession: dbSession, cfg: cfg} + apiReq := &model.APIBatchInstanceCreateRequest{ + TenantID: tenant.ID.String(), + OperatingSystemID: cutil.GetPtr(osSynced.ID.String()), + } + + osConfig, osID, apiErr := h.buildBatchInstanceCreateRequestOsConfig(ec, &logger, apiReq, site) + require.Nil(t, apiErr) + require.NotNil(t, osID) + assert.Equal(t, osSynced.ID, *osID) + assertTemplatedOsConfig(t, osConfig, osSynced.ID) + }) + + // The following cases exercise the shared validator through the create path; + // the same validator gates the update and batch paths. + + // A Local-scope OS (created in nico-core) is a legitimate, usable definition + // once it is present at its Site: selection is gated on site availability, not + // on scope. (osScope only constrains OS creation, per the OS API validation + // rules, where Local is rejected because such OSes originate in core.) + t.Run("allows Local-scope templated OS synchronized to Site", func(t *testing.T) { + osLocal := buildOS("tmpl-os-instance-os-local", cdbm.OperatingSystemScopeLocal) + testInstanceBuildOperatingSystemSiteAssociation(t, dbSession, site.ID, osLocal.ID) + + ec := newTemplatedOsEchoContext(t) + h := CreateInstanceHandler{dbSession: dbSession, cfg: cfg} + apiReq := &model.APIInstanceCreateRequest{ + TenantID: tenant.ID.String(), + OperatingSystemID: cutil.GetPtr(osLocal.ID.String()), + } + + osConfig, osID, apiErr := h.buildInstanceCreateRequestOsConfig(ec, &logger, apiReq, site) + require.Nil(t, apiErr) + require.NotNil(t, osID) + assert.Equal(t, osLocal.ID, *osID) + assertTemplatedOsConfig(t, osConfig, osLocal.ID) + }) + + t.Run("rejects templated OS not synchronized to Site", func(t *testing.T) { + // No Synced association at this Site, regardless of scope. + osUnsynced := buildOS("tmpl-os-instance-os-unsynced", cdbm.OperatingSystemScopeGlobal) + + ec := newTemplatedOsEchoContext(t) + h := CreateInstanceHandler{dbSession: dbSession, cfg: cfg} + apiReq := &model.APIInstanceCreateRequest{ + TenantID: tenant.ID.String(), + OperatingSystemID: cutil.GetPtr(osUnsynced.ID.String()), + } + + osConfig, osID, apiErr := h.buildInstanceCreateRequestOsConfig(ec, &logger, apiReq, site) + require.NotNil(t, apiErr) + assert.Equal(t, http.StatusBadRequest, apiErr.Code) + assert.Nil(t, osConfig) + assert.Nil(t, osID) + }) +} diff --git a/rest-api/api/pkg/api/handler/instancebatch.go b/rest-api/api/pkg/api/handler/instancebatch.go index 91fac19a3a..732f1beac1 100644 --- a/rest-api/api/pkg/api/handler/instancebatch.go +++ b/rest-api/api/pkg/api/handler/instancebatch.go @@ -187,6 +187,9 @@ func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c UserData: apiRequest.UserData, }, osID, nil } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + if apiErr := validateTemplatedIpxeOsForSite(ctx, bcih.dbSession, logger, os, site.ID); apiErr != nil { + return nil, nil, apiErr + } return &corev1.InstanceOperatingSystemConfig{ RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, diff --git a/rest-api/api/pkg/api/handler/ipxetemplate.go b/rest-api/api/pkg/api/handler/ipxetemplate.go index a076a53435..a228b5be54 100644 --- a/rest-api/api/pkg/api/handler/ipxetemplate.go +++ b/rest-api/api/pkg/api/handler/ipxetemplate.go @@ -303,7 +303,12 @@ func (h GetIpxeTemplateHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify iPXE template authorization, DB error", nil) } - if !callerHasAccessToAnyAssociatedSite(ctx, logger, h.dbSession, infrastructureProvider, tenant, associations) { + hasAccess, err := callerHasAccessToAnyAssociatedSite(ctx, logger, h.dbSession, infrastructureProvider, tenant, associations) + if err != nil { + logger.Error().Err(err).Msg("error verifying iPXE template site authorization") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify iPXE template authorization, DB error", nil) + } + if !hasAccess { logger.Warn().Msg("caller is not authorized to access any Site associated with this iPXE template") return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not authorized to access this iPXE template", nil) } @@ -312,8 +317,10 @@ func (h GetIpxeTemplateHandler) Handle(c echo.Context) error { return c.JSON(http.StatusOK, model.NewAPIIpxeTemplate(tmpl)) } -// callerHasAccessToAnyAssociatedSite returns true when the caller (provider or tenant) -// has access to at least one site in the given association set. +// callerHasAccessToAnyAssociatedSite reports whether the caller (provider or +// tenant) has access to at least one site in the given association set. A non-nil +// error indicates a lookup failure and must be surfaced by callers as HTTP 500, +// rather than being collapsed into a "no access" (HTTP 403) answer. func callerHasAccessToAnyAssociatedSite( ctx context.Context, logger zerolog.Logger, @@ -321,9 +328,9 @@ func callerHasAccessToAnyAssociatedSite( provider *cdbm.InfrastructureProvider, tenant *cdbm.Tenant, associations []cdbm.IpxeTemplateSiteAssociation, -) bool { +) (bool, error) { if len(associations) == 0 { - return false + return false, nil } siteIDs := make([]uuid.UUID, 0, len(associations)) @@ -340,10 +347,10 @@ func callerHasAccessToAnyAssociatedSite( }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) if serr != nil { logger.Error().Err(serr).Msg("error retrieving provider sites for iPXE template authorization") - return false + return false, serr } if len(sites) > 0 { - return true + return true, nil } } @@ -356,12 +363,12 @@ func callerHasAccessToAnyAssociatedSite( }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) if terr != nil { logger.Error().Err(terr).Msg("error retrieving Tenant Site associations for iPXE template authorization") - return false + return false, terr } if len(tss) > 0 { - return true + return true, nil } } - return false + return false, nil } diff --git a/rest-api/api/pkg/api/handler/operatingsystem.go b/rest-api/api/pkg/api/handler/operatingsystem.go index f1d295abe5..28873b14a9 100644 --- a/rest-api/api/pkg/api/handler/operatingsystem.go +++ b/rest-api/api/pkg/api/handler/operatingsystem.go @@ -59,8 +59,6 @@ func syncOperatingSystemToSitesViaProxy( fullMethod string, req proto.Message, ) int { - ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) - sdDAO := cdbm.NewStatusDetailDAO(dbSession) siteErrors := 0 for _, ossa := range ossas { slogger := logger.With().Str("Site ID", ossa.SiteID.String()).Logger() @@ -68,7 +66,9 @@ func syncOperatingSystemToSitesViaProxy( stc, cerr := scp.GetClientByID(ossa.SiteID) if cerr != nil { slogger.Error().Err(cerr).Msg("failed to retrieve Temporal client for Site") - updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to connect to site") + // Site is already counted as an error below; a bookkeeping failure is + // logged inside and does not change the outcome for this site. + _ = updateOSSAStatusViaProxy(ctx, slogger, dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to connect to site") siteErrors++ continue } @@ -79,30 +79,45 @@ func syncOperatingSystemToSitesViaProxy( perr := common.ExecuteCoreGRPC(ctx, stc, fullMethod, req, nil, ossa.SiteID.String()) if perr != nil { slogger.Error().Err(perr).Int("code", perr.Code).Msg("failed to sync Operating System to site via Core proxy") - updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to sync Operating System to site") + _ = updateOSSAStatusViaProxy(ctx, slogger, dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to sync Operating System to site") siteErrors++ continue } - updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusSynced, "Operating System successfully synced to site") + // The proxy call succeeded, but if we cannot durably record the Synced + // status the association state is unreliable, so treat that as a site + // error rather than reporting the OS as fully synced. + if serr := updateOSSAStatusViaProxy(ctx, slogger, dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusSynced, "Operating System successfully synced to site"); serr != nil { + siteErrors++ + } } return siteErrors } // updateOSSAStatusViaProxy updates an Operating System Site Association status and -// records a status detail entry. Failures are logged but not propagated, mirroring -// the best-effort status bookkeeping of the existing image sync paths. -func updateOSSAStatusViaProxy(ctx context.Context, logger zerolog.Logger, ossaDAO cdbm.OperatingSystemSiteAssociationDAO, sdDAO cdbm.StatusDetailDAO, ossaID uuid.UUID, status string, message string) { - if _, err := ossaDAO.Update(ctx, nil, cdbm.OperatingSystemSiteAssociationUpdateInput{ - OperatingSystemSiteAssociationID: ossaID, - Status: cutil.GetPtr(status), - }); err != nil { - logger.Error().Err(err).Str("Status", status).Msg("failed to update Operating System Site Association status") - return - } - if _, err := sdDAO.Create(ctx, nil, cdbm.StatusDetailCreateInput{EntityID: ossaID.String(), Status: status, Message: &message}); err != nil { - logger.Error().Err(err).Msg("failed to create status detail for Operating System Site Association") +// records the corresponding status detail atomically in a single transaction, so +// the status and its audit entry cannot diverge. Any persistence error is logged +// and returned so callers can account for it (e.g. as a site error when computing +// aggregate status) instead of silently reporting success. +func updateOSSAStatusViaProxy(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, ossaID uuid.UUID, status string, message string) error { + err := cdb.WithTx(ctx, dbSession, func(tx *cdb.Tx) error { + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + if _, uerr := ossaDAO.Update(ctx, tx, cdbm.OperatingSystemSiteAssociationUpdateInput{ + OperatingSystemSiteAssociationID: ossaID, + Status: cutil.GetPtr(status), + }); uerr != nil { + return uerr + } + if _, cerr := sdDAO.Create(ctx, tx, cdbm.StatusDetailCreateInput{EntityID: ossaID.String(), Status: status, Message: &message}); cerr != nil { + return cerr + } + return nil + }) + if err != nil { + logger.Error().Err(err).Str("Status", status).Msg("failed to persist Operating System Site Association status") } + return err } // updateOperatingSystemAggregateStatus sets the Operating System's aggregate status @@ -578,7 +593,7 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { req := model.BuildCreateOperatingSystemRequest(os) siteErrors := syncOperatingSystemToSitesViaProxy(ctx, logger, csh.dbSession, csh.scp, dbossa, createOperatingSystemMethod, req) updateOperatingSystemAggregateStatus(ctx, logger, csh.dbSession, os.ID, siteErrors > 0) - os, dbossd, dbossa = reloadOperatingSystemForResponse(ctx, logger, csh.dbSession, os) + os, dbossd, dbossa = reloadOperatingSystemForResponse(ctx, logger, csh.dbSession, os, dbossd, dbossa) } // create response @@ -589,8 +604,10 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { // reloadOperatingSystemForResponse re-reads the Operating System, its recent status // details, and its site associations after a proxy sync so the API response reflects -// the post-sync state. Best-effort: on any read error the prior values are kept. -func reloadOperatingSystemForResponse(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, os *cdbm.OperatingSystem) (*cdbm.OperatingSystem, []cdbm.StatusDetail, []cdbm.OperatingSystemSiteAssociation) { +// the post-sync state. Best-effort: the caller passes the values it already holds +// (priorSSDs, priorOSSAs), and each is replaced only on a successful re-read, so a +// read error keeps the prior value rather than dropping it to nil. +func reloadOperatingSystemForResponse(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, os *cdbm.OperatingSystem, priorSSDs []cdbm.StatusDetail, priorOSSAs []cdbm.OperatingSystemSiteAssociation) (*cdbm.OperatingSystem, []cdbm.StatusDetail, []cdbm.OperatingSystemSiteAssociation) { osDAO := cdbm.NewOperatingSystemDAO(dbSession) ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) sdDAO := cdbm.NewStatusDetailDAO(dbSession) @@ -602,14 +619,14 @@ func reloadOperatingSystemForResponse(ctx context.Context, logger zerolog.Logger logger.Warn().Err(err).Msg("failed to reload Operating System for response") } - var ssds []cdbm.StatusDetail + ssds := priorSSDs if v, err := sdDAO.GetRecentByEntityIDs(ctx, nil, []string{os.ID.String()}, common.RECENT_STATUS_DETAIL_COUNT); err == nil { ssds = v } else { logger.Warn().Err(err).Msg("failed to reload Operating System status details for response") } - var ossas []cdbm.OperatingSystemSiteAssociation + ossas := priorOSSAs if v, _, err := ossaDAO.GetAll(ctx, nil, cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}}, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, @@ -1503,7 +1520,7 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { req := model.BuildUpdateOperatingSystemRequest(uos) siteErrors := syncOperatingSystemToSitesViaProxy(ctx, logger, ush.dbSession, ush.scp, ipxeOssas, updateOperatingSystemMethod, req) updateOperatingSystemAggregateStatus(ctx, logger, ush.dbSession, uos.ID, siteErrors > 0) - uos, ssds, dbossas = reloadOperatingSystemForResponse(ctx, logger, ush.dbSession, uos) + uos, ssds, dbossas = reloadOperatingSystemForResponse(ctx, logger, ush.dbSession, uos, ssds, dbossas) } } @@ -1809,6 +1826,30 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { logger.Error().Err(derr).Msg("error creating Status Detail DB entry") return cutil.NewAPIError(http.StatusInternalServerError, "Failed to create Status Detail for Operating System", nil) } + + // Mark each affected association Deleting in the same transaction so the + // in-progress state is durable before any per-site proxy delete runs; the + // post-commit loop transitions each to deleted (row removed) or Error. + for _, ossa := range ossasToDelete { + if ossa.Status == cdbm.OperatingSystemSiteAssociationStatusDeleting { + continue + } + if _, derr := ossaDAO.Update(ctx, tx, cdbm.OperatingSystemSiteAssociationUpdateInput{ + OperatingSystemSiteAssociationID: ossa.ID, + Status: cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusDeleting), + }); derr != nil { + logger.Error().Err(derr).Msg("error updating Operating System Association in DB") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to delete Operating System", nil) + } + if _, derr := sdDAO.Create(ctx, tx, cdbm.StatusDetailCreateInput{ + EntityID: ossa.ID.String(), + Status: cdbm.OperatingSystemSiteAssociationStatusDeleting, + Message: cutil.GetPtr("received request for deletion, pending processing"), + }); derr != nil { + logger.Error().Err(derr).Msg("error creating Status Detail DB entry") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to create Status Detail for Operating System Association", nil) + } + } } // Delete OS if its not Image @@ -1849,6 +1890,7 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { stc, cerr := dsh.scp.GetClientByID(ossa.SiteID) if cerr != nil { slogger.Error().Err(cerr).Msg("failed to retrieve Temporal client for Site") + _ = updateOSSAStatusViaProxy(ctx, slogger, dsh.dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to connect to site") remaining++ continue } @@ -1858,12 +1900,14 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { slogger.Warn().Msg("Operating System not found on site, treating delete as successful") } else { slogger.Error().Err(perr).Int("code", perr.Code).Msg("failed to delete Operating System on site via Core proxy") + _ = updateOSSAStatusViaProxy(ctx, slogger, dsh.dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to delete Operating System on site") remaining++ continue } } if derr := ossaDAO.Delete(ctx, nil, ossa.ID); derr != nil { slogger.Error().Err(derr).Msg("failed to delete Operating System Site Association after site delete") + _ = updateOSSAStatusViaProxy(ctx, slogger, dsh.dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to remove Operating System site association after site delete") remaining++ } } @@ -1872,6 +1916,14 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { logger.Error().Err(derr).Msg("failed to soft-delete Operating System after all sites cleaned up") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System", nil) } + } else { + // Some sites could not be cleaned up. Transition the OS out of the + // transient Deleting state into Error (with per-site Error associations + // recorded above) so it is not stranded in Deleting forever: the state is + // visible via GET and the delete is idempotently retryable (already-gone + // sites return not-found and cleaned-up associations are no longer + // candidates), converging once the sites recover. + updateOperatingSystemAggregateStatus(ctx, logger, dsh.dbSession, os.ID, true) } } diff --git a/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go b/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go index 1179f298a6..a572c7c130 100644 --- a/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go +++ b/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go @@ -18,6 +18,7 @@ import ( "github.com/NVIDIA/infra-controller/rest-api/common/pkg/coreproxy" "github.com/NVIDIA/infra-controller/rest-api/common/pkg/otelecho" cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -174,6 +175,6 @@ func TestOperatingSystemHandler_TemplatedIPXE_Proxy(t *testing.T) { require.NoError(t, perr) osDAO := cdbm.NewOperatingSystemDAO(dbSession) _, gerr := osDAO.GetByID(ctx, nil, parsedID, nil) - assert.Error(t, gerr, "OS should be soft-deleted once every site is cleaned up") + assert.ErrorIs(t, gerr, cdb.ErrDoesNotExist, "OS should be soft-deleted once every site is cleaned up") }) } diff --git a/rest-api/api/pkg/api/model/operatingsystem.go b/rest-api/api/pkg/api/model/operatingsystem.go index e98b74132f..2f32d81e66 100644 --- a/rest-api/api/pkg/api/model/operatingsystem.go +++ b/rest-api/api/pkg/api/model/operatingsystem.go @@ -938,6 +938,9 @@ func validateIpxeTemplateArtifacts(artifacts []cdbm.OperatingSystemIpxeArtifact) if strings.TrimSpace(a.URL) == "" { return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): url is required", i, a.Name)} } + if err := validation.Validate(a.URL, is.URL); err != nil { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): url is not valid: %w", i, a.Name, err)} + } if _, ok := validCacheStrategies[a.CacheStrategy]; !ok { return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): cacheStrategy must be one of CacheAsNeeded, LocalOnly, CachedOnly, RemoteOnly", i, a.Name)} } diff --git a/rest-api/docs/index.html b/rest-api/docs/index.html index 35ba52743a..e336ed742b 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 fiNpIH bAoMjv">

Optional ID of the SKU to associate with this Expected Machine

rackId
string or null

Optional rack identifier for this component

-
bmcIpAddress
string or null

Optional BMC IP address (IPv4 or IPv6). A non-null address sets the value and pre-allocates a reserved IP for the BMC. An explicit null clears the value. In an individual update, omission preserves the current value. Empty strings are invalid.

+
bmcIpAddress
string or null

Optional BMC IP address (IPv4 or IPv6). When set, pre-allocates a reserved IP for the BMC.

name
string or null

Display name for this component

manufacturer
string or null
Typical API Call Flow for Tenant

Request samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Response samples

Content type
application/json
[
  • {
    }
]

Batch Update Expected Machines

Update multiple Expected Machines in a single request. All machines must belong to the same site.

-

All items in a batch update must provide the same set of fields.

Org must have an Infrastructure Provider entity. User must have authorization role with PROVIDER_ADMIN suffix.

Infrastructure Provider must own the Expected Machines.

Alternatively, Tenant Admins with TargetedInstanceCreation capability can also update Expected Machines if they have an account with the Site's Infrastructure Provider.

@@ -4710,8 +4708,8 @@

Typical API Call Flow for Tenant

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

Optional ID of the SKU to associate with this Expected Machine

rackId
string or null

Optional rack identifier for this component

-
bmcIpAddress
string or null

Optional BMC IP address (IPv4 or IPv6). A non-null address sets the value and pre-allocates a reserved IP for the BMC. An explicit null clears the value. In an individual update, omission preserves the current value. Empty strings are invalid.

+
bmcIpAddress
string or null

Optional BMC IP address (IPv4 or IPv6). When set, pre-allocates a reserved IP for the BMC.

name
string or null

Display name for this component

manufacturer
string or null
Typical API Call Flow for Tenant
  • isCloudInit on create and update requests is deprecated and ignored; the response field is derived from whether userData is non-empty.
-

Retrieve all Operating Systems

Get an Operating System by ID

-

If the Operating System has infrastructureProviderId set, then org must have an Infrastructure Provider entity and its ID should match the Operating System Infrastructure Provider ID. User must have authorization role with PROVIDER_ADMIN suffix.

-

If the Operating System has tenantId set, then org must have a Tenant entity and its ID should match the Operating System Tenant ID. User must have authorization role with TENANT_ADMIN suffix.

+

Retrieve all Operating Systems

List Operating Systems visible to the caller's Tenant.

+

Org must have a Tenant entity. User must have authorization role with TENANT_ADMIN suffix. Only Operating Systems whose tenantId matches the caller's Tenant are returned.

Authorizations:
JWTBearerToken
path Parameters
org
required
string

Name of the Org

query Parameters
siteId
string

Filter Operating Systems by Site ID. Can be specified multiple times to filter on more than one ID.

-
type
string
Enum: "Image" "iPXE"
type
string
Enum: "Image" "iPXE" "Templated iPXE"

Filter Operating Systems by Type

status
string

Filter Operating Systems by Status. Can be specified multiple times to filter on more than one status.

@@ -6614,7 +6610,7 @@

Typical API Call Flow for Tenant

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

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string or null
Enum: "iPXE" "Image"
type
string or null
Enum: "iPXE" "Image" "Templated iPXE"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from which the Operating System image can be retrieved

@@ -6632,6 +6628,30 @@

Typical API Call Flow for Tenant

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

Root filesystem label, only applicable for image-based Operating System

ipxeScript
string or null

iPXE script or URL, only applicable for iPXE-based Operating System

+
ipxeTemplateId
string or null

ID of the iPXE template used, only present for Templated iPXE Operating System

+
Array of objects (OperatingSystemIpxeParameter)

Parameters passed to the iPXE template (Templated iPXE only)

+
Array
name
string

Parameter name (used as a variable in the template)

+
value
string

Parameter value

+
Array of objects (OperatingSystemIpxeArtifact)

Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted.

+
Array
name
string

Artifact name

+
url
string

Original URL for the artifact

+
sha
string or null

Optional SHA256 checksum

+
authType
string or null

Optional auth type: Basic or Bearer

+
authToken
string or null

Optional auth token. Redacted in API responses.

+
cacheStrategy
string
Enum: "CacheAsNeeded" "LocalOnly" "CachedOnly" "RemoteOnly"

How to handle caching for this artifact

+
scope
string or null

Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited)

userData
string or null

User data for the Operating System

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

User data for the Operating System

isCloudInit
boolean
Deprecated

Deprecated and ignored: whether the Operating System is cloud-init based. Value now derived from userData.

-
allowOverride
boolean
allowOverride
boolean

Indicates if the user data can be overridden at Instance creation time

+
ipxeTemplateId
string or null

ID of the iPXE template to use; identifies a Templated iPXE Operating System. Mutually exclusive with ipxeScript and imageUrl.

+
Array of objects (OperatingSystemIpxeParameter)

Parameters passed to the iPXE template (Templated iPXE only).

+
Array
name
string

Parameter name (used as a variable in the template)

+
value
string

Parameter value

+
Array of objects (OperatingSystemIpxeArtifact)

Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only).

+
Array
name
string

Artifact name

+
url
string

Original URL for the artifact

+
sha
string or null

Optional SHA256 checksum

+
authType
string or null

Optional auth type: Basic or Bearer

+
authToken
string or null

Optional auth token. Redacted in API responses.

+
cacheStrategy
string
Enum: "CacheAsNeeded" "LocalOnly" "CachedOnly" "RemoteOnly"

How to handle caching for this artifact

+
scope
string or null
Enum: "Local" "Global" "Limited" null

Synchronization scope for iPXE-based Operating Systems. Required for Templated iPXE (Global or Limited; Local is created only in nico-core).

Responses

Response Schema: application/json
id
string <uuid>
Typical API Call Flow for Tenant " class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv">

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string or null
Enum: "iPXE" "Image"
type
string or null
Enum: "iPXE" "Image" "Templated iPXE"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from which the Operating System image can be retrieved

@@ -6752,6 +6796,30 @@

Typical API Call Flow for Tenant

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

Root filesystem label, only applicable for image-based Operating System

ipxeScript
string or null

iPXE script or URL, only applicable for iPXE-based Operating System

+
ipxeTemplateId
string or null

ID of the iPXE template used, only present for Templated iPXE Operating System

+
Array of objects (OperatingSystemIpxeParameter)

Parameters passed to the iPXE template (Templated iPXE only)

+
Array
name
string

Parameter name (used as a variable in the template)

+
value
string

Parameter value

+
Array of objects (OperatingSystemIpxeArtifact)

Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted.

+
Array
name
string

Artifact name

+
url
string

Original URL for the artifact

+
sha
string or null

Optional SHA256 checksum

+
authType
string or null

Optional auth type: Basic or Bearer

+
authToken
string or null

Optional auth token. Redacted in API responses.

+
cacheStrategy
string
Enum: "CacheAsNeeded" "LocalOnly" "CachedOnly" "RemoteOnly"

How to handle caching for this artifact

+
scope
string or null

Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited)

userData
string or null

User data for the Operating System

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

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string or null
Enum: "iPXE" "Image"
type
string or null
Enum: "iPXE" "Image" "Templated iPXE"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from which the Operating System image can be retrieved

@@ -6840,6 +6908,30 @@

Typical API Call Flow for Tenant

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

Root filesystem label, only applicable for image-based Operating System

ipxeScript
string or null

iPXE script or URL, only applicable for iPXE-based Operating System

+
ipxeTemplateId
string or null

ID of the iPXE template used, only present for Templated iPXE Operating System

+
Array of objects (OperatingSystemIpxeParameter)

Parameters passed to the iPXE template (Templated iPXE only)

+
Array
name
string

Parameter name (used as a variable in the template)

+
value
string

Parameter value

+
Array of objects (OperatingSystemIpxeArtifact)

Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted.

+
Array
name
string

Artifact name

+
url
string

Original URL for the artifact

+
sha
string or null

Optional SHA256 checksum

+
authType
string or null

Optional auth type: Basic or Bearer

+
authToken
string or null

Optional auth token. Redacted in API responses.

+
cacheStrategy
string
Enum: "CacheAsNeeded" "LocalOnly" "CachedOnly" "RemoteOnly"

How to handle caching for this artifact

+
scope
string or null

Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited)

userData
string or null

User data for the Operating System

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

Indicates if the user data can be overridden at Instance creation time

isActive
boolean or null

Indicates if the Operating System is active

-
deactivationNote
string or null
deactivationNote
string or null

Optional deactivation note if OS is inactive

+
ipxeTemplateId
string or null

ID of the iPXE template to use (Templated iPXE only). Mutually exclusive with ipxeScript and imageUrl.

+
Array of objects (OperatingSystemIpxeParameter)

Parameters passed to the iPXE template (Templated iPXE only).

+
Array
name
string

Parameter name (used as a variable in the template)

+
value
string

Parameter value

+
Array of objects (OperatingSystemIpxeArtifact)

Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only).

+
Array
name
string

Artifact name

+
url
string

Original URL for the artifact

+
sha
string or null

Optional SHA256 checksum

+
authType
string or null

Optional auth type: Basic or Bearer

+
authToken
string or null

Optional auth token. Redacted in API responses.

+
cacheStrategy
string
Enum: "CacheAsNeeded" "LocalOnly" "CachedOnly" "RemoteOnly"

How to handle caching for this artifact

+
scope
string or null

Scope is immutable after creation; if provided the request is rejected.

Responses

Response Schema: application/json
id
string <uuid>
Typical API Call Flow for Tenant " class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv">

Specified if a Provider owns the Operating System

tenantId
string or null <uuid>

Specified if a Tenant owns the Operating System

-
type
string or null
Enum: "iPXE" "Image"
type
string or null
Enum: "iPXE" "Image" "Templated iPXE"

Type of the Operating System

imageUrl
string or null <uri>

Original URL from which the Operating System image can be retrieved

@@ -6972,6 +7088,30 @@

Typical API Call Flow for Tenant

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

Root filesystem label, only applicable for image-based Operating System

ipxeScript
string or null

iPXE script or URL, only applicable for iPXE-based Operating System

+
ipxeTemplateId
string or null

ID of the iPXE template used, only present for Templated iPXE Operating System

+
Array of objects (OperatingSystemIpxeParameter)

Parameters passed to the iPXE template (Templated iPXE only)

+
Array
name
string

Parameter name (used as a variable in the template)

+
value
string

Parameter value

+
Array of objects (OperatingSystemIpxeArtifact)

Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted.

+
Array
name
string

Artifact name

+
url
string

Original URL for the artifact

+
sha
string or null

Optional SHA256 checksum

+
authType
string or null

Optional auth type: Basic or Bearer

+
authToken
string or null

Optional auth token. Redacted in API responses.

+
cacheStrategy
string
Enum: "CacheAsNeeded" "LocalOnly" "CachedOnly" "RemoteOnly"

How to handle caching for this artifact

+
scope
string or null

Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited)

userData
string or null

User data for the Operating System

isCloudInit
boolean
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
[
  • {
    }
]

Retrieve DPU Machines attached to a host Machine

https://nico-rest-api.nico.svc.cluster.local/v2/org/{org}/nico/machine/{machineId}/status-history

Response samples

Content type
application/json
[
  • {
    }
]

Retrieve DPU Machines attached to a host Machine

Typical API Call Flow for Tenant <li>The caller&#39;s org has an Infrastructure Provider entity that owns the Machine&#39;s Site, and the user has authorization role with <code>PROVIDER_ADMIN</code> suffix on that org.</li> <li>The caller&#39;s org has a Tenant entity with <code>TargetedInstanceCreation</code> enabled and an active Tenant Account on the Machine&#39;s Site&#39;s Infrastructure Provider, and the user has authorization role with <code>TENANT_ADMIN</code> suffix on that org.</li> </ul> -" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV">

Retrieve DPU Machines attached to the host Machine, including network configuration fields exposed by the REST API. Internal-only and sensitive fields from the Core configuration are omitted.

+" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV">

Retrieve DPU Machines attached to the host Machine, including the full network configuration sent to the DPU agent.

The response is built by scheduling a synchronous Temporal GetDpuMachines workflow against the Machine's Site for the DPU Machine IDs referenced by the host Machine's interfaces.

Authorization:

Access is restricted to users associated with the Machine's Site. Either of the following grants access:

@@ -10210,8 +10350,8 @@

Typical API Call Flow for Tenant

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

Labels associated with the DPU

property name*
additional property
string
state
required
string

Lifecycle state of the DPU

-
required
object (DpuNetworkConfig)

Network configuration fields exposed by the REST API

+
required
object (DpuNetworkConfig)

Complete network configuration sent to the DPU agent

asn
required
integer <uint32>

Autonomous System Number for BGP routing

dhcpServers
Array of strings
Typical API Call Flow for Tenant
  • rackLevelAdministration capability attribute was deprecated in favor of flow and was removed on May 13th, 2026 0:00 UTC. Please use flow instead.
  • isRackLevelAdministrationEnabled query parameter was deprecated in favor of isFlowEnabled and was removed on May 13th, 2026 0:00 UTC. Please use isFlowEnabled instead.
  • -
    +

    iPXE Template

    Get all iPXE templates

    Get all iPXE templates propagated from nico-core. Optionally restrict to one or more sites with the siteId query parameter.

    +
    Authorizations:
    JWTBearerToken
    path Parameters
    org
    required
    string

    Name of the Org

    +
    query Parameters
    siteId
    Array of strings <uuid> [ items <uuid > ]

    Optional site ID(s); may be repeated to restrict results to templates available at any of the sites

    +
    pageNumber
    integer >= 1
    Default: 1
    Example: pageNumber=1

    Page number for pagination query

    +
    pageSize
    integer [ 1 .. 100 ]
    Example: pageSize=20

    Page size for pagination query

    +
    orderBy
    string
    Enum: "NAME_ASC" "NAME_DESC" "CREATED_ASC" "CREATED_DESC" "UPDATED_ASC" "UPDATED_DESC"

    Ordering for pagination query

    +

    Responses

    Response Headers
    X-Pagination
    string
    Example: "{\"pageNumber\":1,\"pageSize\":20,\"total\":30,\"orderBy\": \"NAME_ASC\"}"

    Pagination result in JSON format

    +
    Response Schema: application/json
    Array
    id
    string <uuid>

    Stable template UUID assigned by core

    +
    name
    string

    Globally unique template name

    +
    template
    string

    Raw iPXE script content

    +
    requiredParams
    Array of strings

    Parameters that must be provided to render the template

    +
    reservedParams
    Array of strings

    Parameters reserved by the template and not user-supplied

    +
    requiredArtifacts
    Array of strings

    Artifact names required for the template

    +
    visibility
    string

    Template visibility: Internal or Public

    +
    created
    string <date-time>
    updated
    string <date-time>

    Response samples

    Content type
    application/json
    [
    • {
      }
    ]

    Retrieve an iPXE template

    Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is available.

    +
    Authorizations:
    JWTBearerToken
    path Parameters
    org
    required
    string

    Name of the Org

    +
    ipxeTemplateId
    required
    string <uuid>

    Stable template ID (UUID from core)

    +

    Responses

    Response Schema: application/json
    id
    string <uuid>

    Stable template UUID assigned by core

    +
    name
    string

    Globally unique template name

    +
    template
    string

    Raw iPXE script content

    +
    requiredParams
    Array of strings

    Parameters that must be provided to render the template

    +
    reservedParams
    Array of strings

    Parameters reserved by the template and not user-supplied

    +
    requiredArtifacts
    Array of strings

    Artifact names required for the template

    +
    visibility
    string

    Template visibility: Internal or Public

    +
    created
    string <date-time>
    updated
    string <date-time>

    Response samples

    Content type
    application/json
    {
    • "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    • "name": "ubuntu-autoinstall",
    • "template": "#!ipxe\nkernel ${kernel_url} initrd=initrd autoinstall\ninitrd ${initrd_url}\nboot\n",
    • "requiredParams": [
      ],
    • "reservedParams": [
      ],
    • "requiredArtifacts": [
      ],
    • "visibility": "Public",
    • "created": "2026-07-14T14:15:22Z",
    • "updated": "2026-07-14T14:15:22Z"
    }