diff --git a/rest-api/api/pkg/api/handler/instance.go b/rest-api/api/pkg/api/handler/instance.go index 55cfc96a8b..5a06f18097 100644 --- a/rest-api/api/pkg/api/handler/instance.go +++ b/rest-api/api/pkg/api/handler/instance.go @@ -89,6 +89,45 @@ 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 independent of how the OS was created or which side owns +// its definition: single-site OSes sync bidirectionally with nico-core, while +// multi-site OSes are REST-owned and pushed out to their Sites. 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. @@ -213,6 +252,20 @@ 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, + 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 +2151,20 @@ 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, + 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/instance_test.go b/rest-api/api/pkg/api/handler/instance_test.go index 4269c0c515..05b60f4efe 100644 --- a/rest-api/api/pkg/api/handler/instance_test.go +++ b/rest-api/api/pkg/api/handler/instance_test.go @@ -35,6 +35,7 @@ import ( swe "github.com/NVIDIA/infra-controller/rest-api/site-workflow/pkg/error" "github.com/google/uuid" "github.com/labstack/echo/v4" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -10292,3 +10293,187 @@ func TestInstanceHandler_GetStatusDetails(t *testing.T) { }) } } + +// 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) { + 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. + buildOS := func(name string) *cdbm.OperatingSystem { + return testInstanceBuildOperatingSystem(t, dbSession, name, tenant, + cdbm.OperatingSystemTypeTemplatedIPXE, false, nil, false, cdbm.OperatingSystemStatusReady, user) + } + + // An OS synchronized (Synced association) to the Site: the happy path. + osSynced := buildOS("tmpl-os-instance-os-synced") + 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. + + // Selection is gated on site availability, not on ownership: any Templated iPXE + // OS that is present (Synced association) at its Site is a usable definition. + t.Run("allows any templated OS synchronized to Site", func(t *testing.T) { + osOther := buildOS("tmpl-os-instance-os-other") + testInstanceBuildOperatingSystemSiteAssociation(t, dbSession, site.ID, osOther.ID) + + ec := newTemplatedOsEchoContext(t) + h := CreateInstanceHandler{dbSession: dbSession, cfg: cfg} + apiReq := &model.APIInstanceCreateRequest{ + TenantID: tenant.ID.String(), + OperatingSystemID: cutil.GetPtr(osOther.ID.String()), + } + + osConfig, osID, apiErr := h.buildInstanceCreateRequestOsConfig(ec, &logger, apiReq, site) + require.Nil(t, apiErr) + require.NotNil(t, osID) + assert.Equal(t, osOther.ID, *osID) + assertTemplatedOsConfig(t, osConfig, osOther.ID) + }) + + // Only a Synced association marks a definition as available at the Site. An + // OS with no association, or one whose association is still Syncing or has + // Errored, must be rejected with BadRequest and no osConfig/osID. + rejectCases := []struct { + name string + status *string // nil => no association at all + }{ + {name: "rejects templated OS not synchronized to Site", status: nil}, + {name: "rejects templated OS with Syncing association", status: cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing)}, + {name: "rejects templated OS with Error association", status: cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusError)}, + } + for i, tc := range rejectCases { + t.Run(tc.name, func(t *testing.T) { + os := buildOS(fmt.Sprintf("tmpl-os-instance-os-reject-%d", i)) + if tc.status != nil { + buildOperatingSystemSiteAssociationWithStatus(t, dbSession, site.ID, os.ID, *tc.status) + } + + ec := newTemplatedOsEchoContext(t) + h := CreateInstanceHandler{dbSession: dbSession, cfg: cfg} + apiReq := &model.APIInstanceCreateRequest{ + TenantID: tenant.ID.String(), + OperatingSystemID: cutil.GetPtr(os.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) + }) + } +} + +// buildOperatingSystemSiteAssociationWithStatus inserts an +// OperatingSystemSiteAssociation with an explicit status, so tests can exercise +// non-Synced states (Syncing / Error) that the templated-OS site validator must +// reject. +func buildOperatingSystemSiteAssociationWithStatus(t *testing.T, dbSession *cdb.Session, siteID, osID uuid.UUID, status string) { + t.Helper() + ossa := &cdbm.OperatingSystemSiteAssociation{ + ID: uuid.New(), + OperatingSystemID: osID, + SiteID: siteID, + Version: cutil.GetPtr("1234"), + Status: status, + Created: cdb.GetCurTime(), + Updated: cdb.GetCurTime(), + } + _, err := dbSession.DB.NewInsert().Model(ossa).Exec(context.Background()) + require.NoError(t, err) +} diff --git a/rest-api/api/pkg/api/handler/instancebatch.go b/rest-api/api/pkg/api/handler/instancebatch.go index 6b693b26b5..732f1beac1 100644 --- a/rest-api/api/pkg/api/handler/instancebatch.go +++ b/rest-api/api/pkg/api/handler/instancebatch.go @@ -186,6 +186,20 @@ 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, + 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..1985eaf38f --- /dev/null +++ b/rest-api/api/pkg/api/handler/ipxetemplate.go @@ -0,0 +1,378 @@ +// 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. + if err := common.ValidateKnownQueryParams(c.QueryParams(), model.APIIpxeTemplateGetAllRequest{}, pagination.PageRequest{}); err != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, err.Error(), nil) + } + + requestedSiteIDStrs := c.QueryParams()["siteId"] + requestedSiteIDs := make([]uuid.UUID, 0, len(requestedSiteIDStrs)) + for _, s := range requestedSiteIDStrs { + if s == "" { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid siteId in query parameter: empty value", nil) + } + 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) + } + + 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) + } + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, model.NewAPIIpxeTemplate(tmpl)) +} + +// 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, + dbSession *cdb.Session, + provider *cdbm.InfrastructureProvider, + tenant *cdbm.Tenant, + associations []cdbm.IpxeTemplateSiteAssociation, +) (bool, error) { + if len(associations) == 0 { + return false, nil + } + + 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, serr + } + if len(sites) > 0 { + return true, nil + } + } + + // 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, terr + } + if len(tss) > 0 { + return true, nil + } + } + + return false, nil +} 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..76424a6a64 --- /dev/null +++ b/rest-api/api/pkg/api/handler/ipxetemplate_test.go @@ -0,0 +1,365 @@ +// 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" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/pagination" + 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" + cdbp "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" + "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 + queryParams url.Values + expectedErr bool + expectedStatus int + expectedNames []string + expectedPageNumber int + expectedPageSize int + expectedTotal int + }{ + { + 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}, + expectedPageNumber: 1, + expectedPageSize: cdbp.DefaultLimit, + expectedTotal: 1, + }, + { + 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}, + expectedPageNumber: 1, + expectedPageSize: cdbp.DefaultLimit, + expectedTotal: 1, + }, + { + 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}, + expectedPageNumber: 1, + expectedPageSize: cdbp.DefaultLimit, + expectedTotal: 1, + }, + { + name: "provider admin with unauthorized siteId is forbidden", + reqOrgName: f.ipOrg, + user: f.provUser, + siteID: cutilPtrUUID(uuid.New()), + expectedErr: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "error when siteId is empty", + reqOrgName: f.ipOrg, + user: f.provUser, + queryParams: url.Values{"siteId": {""}}, + expectedErr: true, + expectedStatus: http.StatusBadRequest, + }, + { + name: "error when unknown query parameter is specified", + reqOrgName: f.ipOrg, + user: f.provUser, + queryParams: url.Values{"foo": {"bar"}}, + expectedErr: true, + expectedStatus: http.StatusBadRequest, + }, + { + 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.queryParams != nil { + q = tc2.queryParams + } else 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") + + paginationHeader := rec.Header().Get(pagination.ResponseHeaderName) + assert.NotEmpty(t, paginationHeader) + var pageResp pagination.PageResponse + err = json.Unmarshal([]byte(paginationHeader), &pageResp) + require.NoError(t, err) + assert.Equal(t, tc2.expectedPageNumber, pageResp.PageNumber) + assert.Equal(t, tc2.expectedPageSize, pageResp.PageSize) + assert.Equal(t, tc2.expectedTotal, pageResp.Total) + } + }) + } +} + +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..75128ae184 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,206 @@ 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 { + 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") + // 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 + } + + // 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, dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to sync Operating System to site") + siteErrors++ + continue + } + + // 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 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 +} + +// aggregateSyncMessage returns the status-detail message for a proxy sync aggregate +// status update (create/update), where the outcome is either a full sync or a +// partial/complete sync failure. +func aggregateSyncMessage(hadErrors bool) string { + if hadErrors { + return "failed to sync Operating System to one or more sites" + } + return "Operating System successfully synced to all sites" +} + +// updateOperatingSystemAggregateStatus sets the Operating System's aggregate status +// after a proxy sync attempt: Ready when all sites synced, Error when one or more failed. +// Callers supply the operation-specific status-detail message (e.g. a sync message for +// create/update, a deletion message for delete). The status update and its status-detail +// audit entry are written together in a single transaction so they cannot diverge, and +// any transaction/DB failure is returned to the caller rather than only logged. +// +// A Deactivated Operating System keeps its Deactivated status: the sync still pushes the +// definition to sites, but the aggregate readiness/error status must not override the +// user-initiated deactivation. +func updateOperatingSystemAggregateStatus(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, osID uuid.UUID, hadErrors bool, message string) error { + osDAO := cdbm.NewOperatingSystemDAO(dbSession) + + status := cdbm.OperatingSystemStatusReady + if hadErrors { + status = cdbm.OperatingSystemStatusError + } + + if err := cdb.WithTx(ctx, dbSession, func(tx *cdb.Tx) error { + // Serialize with other Operating System state changes (deactivation, + // deletion) via the per-OS advisory lock, held for the life of this + // transaction. This closes the read-then-write race: the deactivation guard + // re-reads the status under the lock, so a concurrent Deactivate cannot land + // between the read and the write and be clobbered by Ready/Error. + if lerr := tx.TryAcquireAdvisoryLock(ctx, cdb.GetAdvisoryLockIDFromString(osID.String()), nil); lerr != nil { + logger.Error().Err(lerr).Msg("failed to acquire advisory lock on Operating System for aggregate status update") + return lerr + } + + existing, gerr := osDAO.GetByID(ctx, tx, osID, nil) + if gerr != nil { + logger.Error().Err(gerr).Msg("failed to read Operating System before aggregate status update") + return gerr + } + // A Deactivated Operating System keeps its Deactivated status: the sync still + // pushed the definition to sites, but the aggregate readiness/error status + // must not override the user-initiated deactivation. + if existing.Status == cdbm.OperatingSystemStatusDeactivated { + logger.Info().Msg("Operating System is deactivated, preserving Deactivated status after site sync") + return nil + } + + if _, uerr := osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{OperatingSystemId: osID, Status: &status}); uerr != nil { + return uerr + } + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + if _, cerr := sdDAO.Create(ctx, tx, cdbm.StatusDetailCreateInput{EntityID: osID.String(), Status: status, Message: &message}); cerr != nil { + return cerr + } + return nil + }); err != nil { + logger.Error().Err(err).Msg("failed to persist aggregate Operating System status") + return err + } + return nil +} + +// validateIpxeTemplateAvailableAtSites verifies the referenced iPXE template is +// available (has an IpxeTemplateSiteAssociation) at every one of the given Sites. +// A Templated iPXE Operating System is rendered on the Site from its template, so +// per the OS sync contract it can only be synced to a Site whose Site currently has +// that template; creating or updating one for a Site that lacks it would persist a +// definition that can never render there. templateID must be a valid template UUID; +// a non-existent template (no associations anywhere) is reported as unavailable at +// every Site. An empty site set is a no-op. +func validateIpxeTemplateAvailableAtSites(ctx context.Context, dbSession *cdb.Session, logger zerolog.Logger, templateID string, siteIDs []uuid.UUID) *cutil.APIError { + if len(siteIDs) == 0 { + return nil + } + + tid, perr := uuid.Parse(templateID) + if perr != nil { + return cutil.NewAPIError(http.StatusBadRequest, "iPXE template ID specified in request is not a valid UUID", nil) + } + + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + itsas, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{ + IpxeTemplateIDs: []uuid.UUID{tid}, + SiteIDs: siteIDs, + }, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Str("ipxeTemplateId", templateID).Msg("error retrieving iPXE template Site associations for Operating System validation") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to validate iPXE template availability, DB error", nil) + } + + available := make(map[uuid.UUID]struct{}, len(itsas)) + for _, a := range itsas { + available[a.SiteID] = struct{}{} + } + missing := make([]string, 0) + for _, sid := range siteIDs { + if _, ok := available[sid]; !ok { + missing = append(missing, sid.String()) + } + } + if len(missing) > 0 { + logger.Warn().Str("ipxeTemplateId", templateID).Msg("iPXE template is not available at one or more target Sites") + return cutil.NewAPIError(http.StatusBadRequest, fmt.Sprintf("iPXE template %s specified in request is not available at Site(s): %v", templateID, missing), nil) + } + return nil +} + // ~~~~~ Create Handler ~~~~~ // // CreateOperatingSystemHandler is the API Handler for creating new OperatingSystem @@ -159,11 +361,9 @@ 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() // Set the phoneHomeEnabled if provided in request phoneHomeEnabled := false @@ -232,10 +432,26 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { rdbst = append(rdbst, *site) } - // Create status based on OS type + // A Templated iPXE Operating System is rendered on-Site from its iPXE template, + // so it can only be synced to Sites that currently have the template. Reject up + // front if the referenced template is unavailable at any target Site rather than + // persist a definition that can never render there. + if osType == cdbm.OperatingSystemTypeTemplatedIPXE { + targetSiteIDs := make([]uuid.UUID, 0, len(rdbst)) + for i := range rdbst { + targetSiteIDs = append(targetSiteIDs, rdbst[i].ID) + } + if apiErr := validateIpxeTemplateAvailableAtSites(ctx, csh.dbSession, logger, *apiRequest.IpxeTemplateId, targetSiteIDs); apiErr != nil { + return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, apiErr.Data) + } + } + + // 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 +467,28 @@ 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.ToDBModel(), + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts.ToDBModel(), + 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 +563,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 +645,61 @@ 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) + if aerr := updateOperatingSystemAggregateStatus(ctx, logger, csh.dbSession, os.ID, siteErrors > 0, aggregateSyncMessage(siteErrors > 0)); aerr != nil { + logger.Error().Err(aerr).Msg("failed to update aggregate Operating System status after create sync") + } + os, dbossd, dbossa = reloadOperatingSystemForResponse(ctx, logger, csh.dbSession, os, dbossd, dbossa) + } + // 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: 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) + + 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") + } + + 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") + } + + ossas := priorOSSAs + 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 +964,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) @@ -1087,6 +1356,34 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { } } + // For a Templated iPXE Operating System, verify the effective iPXE template (the + // request's template when changing it, otherwise the current one) is available at + // every Site the OS is synced to before updating and re-pushing it. This mirrors + // the create-time check and also catches a request switching to a template that + // is not present at the OS's Sites. + if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + templatedOssas, _, oerr := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if oerr != nil { + logger.Error().Err(oerr).Msg("error retrieving Operating System Site associations for iPXE template validation") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } + effectiveTemplateID := os.IpxeTemplateId + if apiRequest.IpxeTemplateId != nil { + effectiveTemplateID = apiRequest.IpxeTemplateId + } + if effectiveTemplateID != nil && len(templatedOssas) > 0 { + targetSiteIDs := make([]uuid.UUID, 0, len(templatedOssas)) + for _, o := range templatedOssas { + targetSiteIDs = append(targetSiteIDs, o.SiteID) + } + if apiErr := validateIpxeTemplateAvailableAtSites(ctx, ush.dbSession, logger, *effectiveTemplateID, targetSiteIDs); apiErr != nil { + return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, apiErr.Data) + } + } + } + // Save update status in DB osStatus := cutil.GetPtr(cdbm.OperatingSystemStatusReady) osStatusMessage := "Operating System has been updated and ready for use" @@ -1100,7 +1397,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 +1423,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.ToDBModelPtr(), + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts.ToDBModelPtr(), + 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") @@ -1277,6 +1577,44 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { dbossas = refreshedOssas } + // Templated iPXE updates re-push the definition to every associated Site via + // the Core proxy after commit. Mark each association (and its status detail) + // Syncing inside this transaction so the in-flight state is durable before any + // proxy update runs: validateTemplatedIpxeOsForSite gates Instance selection on + // a Synced association, so this prevents an Instance from being created against a + // definition that is mid-update. The post-commit proxy sync transitions each + // association to Synced or Error. + if uos.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + tmplOssas, _, derr := ossaDAO.GetAll( + ctx, + tx, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{uos.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if derr != nil { + logger.Error().Err(derr).Msg("error retrieving Operating System Site associations for templated iPXE update") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve Operating System Site associations, DB error", nil) + } + for _, tossa := range tmplOssas { + if _, derr := ossaDAO.Update(ctx, tx, cdbm.OperatingSystemSiteAssociationUpdateInput{ + OperatingSystemSiteAssociationID: tossa.ID, + Status: cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing), + }); derr != nil { + logger.Error().Err(derr).Msg("unable to update the Operating System association record in DB") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Operating System Site Association status, DB error", nil) + } + if _, derr := sdDAO.Create(ctx, tx, cdbm.StatusDetailCreateInput{ + EntityID: tossa.ID.String(), + Status: cdbm.OperatingSystemSiteAssociationStatusSyncing, + Message: cutil.GetPtr("received Operating System Association update request, syncing"), + }); 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 Site Association", nil) + } + } + } + return nil }) // The wrapping `if err != nil` ensures real tx-helper errors (commit / @@ -1293,6 +1631,28 @@ 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) + if aerr := updateOperatingSystemAggregateStatus(ctx, logger, ush.dbSession, uos.ID, siteErrors > 0, aggregateSyncMessage(siteErrors > 0)); aerr != nil { + logger.Error().Err(aerr).Msg("failed to update aggregate Operating System status after update sync") + } + uos, ssds, dbossas = reloadOperatingSystemForResponse(ctx, logger, ush.dbSession, uos, ssds, dbossas) + } + } + // Send response apiOperatingSystem := model.NewAPIOperatingSystem(uos, ssds, dbossas, sttsmap) logger.Info().Msg("finishing API handler") @@ -1403,7 +1763,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 +1781,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 +1937,50 @@ 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) + } + + // 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 // 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 +2007,57 @@ 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") + _ = updateOSSAStatusViaProxy(ctx, slogger, dsh.dbSession, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to connect to 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") + _ = 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++ + } + } + 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) + } + } 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. + if aerr := updateOperatingSystemAggregateStatus(ctx, logger, dsh.dbSession, os.ID, true, "failed to delete Operating System from one or more sites"); aerr != nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update Operating System status", 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..ca86d851ba --- /dev/null +++ b/rest-api/api/pkg/api/handler/operatingsystem_templated_proxy_test.go @@ -0,0 +1,438 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/api/internal/config" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site" + 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" + 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" + corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + enums "go.temporal.io/api/enums/v1" + tmocks "go.temporal.io/sdk/mocks" + tp "go.temporal.io/sdk/temporal" + "google.golang.org/protobuf/encoding/protojson" +) + +// templatedProxyFixture wires up a tenant-owned Templated iPXE OS test +// environment with one registered site and a Public template available there. +type templatedProxyFixture struct { + ctx context.Context + dbSession *cdb.Session + cfg *config.Config + scp *sc.ClientPool + tc *tmocks.Client + tracer trace.Tracer + + ipOrg string + tnOrg string + tnu *cdbm.User + site *cdbm.Site + tmpl *cdbm.IpxeTemplate +} + +func buildTemplatedProxyFixture(t *testing.T) *templatedProxyFixture { + t.Helper() + + ctx := context.Background() + dbSession := testMachineInitDB(t) + common.TestSetupSchema(t, dbSession) + + cfg := common.GetTestConfig() + + ipOrg := "tmpl-proxy-ip-org" + tnOrg := "tmpl-proxy-tn-org" + + 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) + + 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) + + tcfg, _ := cfg.GetTemporalConfig() + scp := sc.NewClientPool(tcfg) + + t.Cleanup(func() { dbSession.Close() }) + + return &templatedProxyFixture{ + ctx: ctx, + dbSession: dbSession, + cfg: cfg, + scp: scp, + tc: &tmocks.Client{}, + tracer: tracer, + ipOrg: ipOrg, + tnOrg: tnOrg, + tnu: tnu, + site: site, + tmpl: tmpl, + } +} + +type proxySiteClient struct { + client *tmocks.Client + workflow *tmocks.WorkflowRun + captured coreproxy.Request +} + +func newProxySiteClient(t *testing.T, wantMethod string, getErr, executeErr error) *proxySiteClient { + t.Helper() + + psc := &proxySiteClient{ + workflow: &tmocks.WorkflowRun{}, + client: &tmocks.Client{}, + } + psc.client.On( + "ExecuteWorkflow", + mock.Anything, + mock.Anything, + coreproxy.WorkflowName, + mock.MatchedBy(func(req coreproxy.Request) bool { + if req.FullMethod != wantMethod { + return false + } + psc.captured = req + return true + }), + ).Return(psc.workflow, executeErr).Once() + if executeErr == nil { + psc.workflow.On("Get", mock.Anything, mock.Anything).Return(getErr).Once() + } + + return psc +} + +func (f *templatedProxyFixture) bindProxyClient(psc *proxySiteClient) { + f.scp.IDClientMap[f.site.ID.String()] = psc.client +} + +func (f *templatedProxyFixture) newEchoContext(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", f.tnu) + reqCtx := context.WithValue(f.ctx, otelecho.TracerKey, f.tracer) + ec.SetRequest(ec.Request().WithContext(reqCtx)) + return ec, rec +} + +func assertProxyCreatePayload(t *testing.T, tnOrg, tmplID string, reqJSON []byte, wantVersion string) { + t.Helper() + var coreReq corev1.CreateOperatingSystemRequest + require.NoError(t, protojson.Unmarshal(reqJSON, &coreReq)) + assert.Equal(t, "tmpl-proxy-os", coreReq.Name) + assert.Equal(t, tnOrg, coreReq.GetTenantOrganizationId()) + assert.Equal(t, tmplID, coreReq.GetIpxeTemplateId().GetValue()) + require.Len(t, coreReq.IpxeTemplateParameters, 1) + assert.Equal(t, "version", coreReq.IpxeTemplateParameters[0].Name) + assert.Equal(t, wantVersion, coreReq.IpxeTemplateParameters[0].Value) + assert.NotEmpty(t, coreReq.GetId().GetValue()) +} + +func assertProxyUpdatePayload(t *testing.T, osID, tmplID string, reqJSON []byte, wantVersion string) { + t.Helper() + var coreReq corev1.UpdateOperatingSystemRequest + require.NoError(t, protojson.Unmarshal(reqJSON, &coreReq)) + assert.Equal(t, osID, coreReq.GetId().GetValue()) + assert.Equal(t, tmplID, coreReq.GetIpxeTemplateId().GetValue()) + require.NotNil(t, coreReq.IpxeTemplateParameters) + require.Len(t, coreReq.IpxeTemplateParameters.Items, 1) + assert.Equal(t, "version", coreReq.IpxeTemplateParameters.Items[0].Name) + assert.Equal(t, wantVersion, coreReq.IpxeTemplateParameters.Items[0].Value) +} + +func assertProxyDeletePayload(t *testing.T, osID string, reqJSON []byte) { + t.Helper() + var coreReq corev1.DeleteOperatingSystemRequest + require.NoError(t, protojson.Unmarshal(reqJSON, &coreReq)) + assert.Equal(t, osID, coreReq.GetId().GetValue()) +} + +func (f *templatedProxyFixture) osAssociationStatus(t *testing.T, osID uuid.UUID) string { + t.Helper() + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(f.dbSession) + ossas, _, err := ossaDAO.GetAll(f.ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{osID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + require.NoError(t, err) + require.Len(t, ossas, 1) + return ossas[0].Status +} + +func (f *templatedProxyFixture) osStatus(t *testing.T, osID uuid.UUID) string { + t.Helper() + osDAO := cdbm.NewOperatingSystemDAO(f.dbSession) + os, err := osDAO.GetByID(f.ctx, nil, osID, nil) + require.NoError(t, err) + return os.Status +} + +// TestOperatingSystemHandler_TemplatedIPXE_Proxy exercises the full create / +// update / delete lifecycle of a 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) { + f := buildTemplatedProxyFixture(t) + + var osID string + + t.Run("create Templated iPXE syncs to sites via proxy", func(t *testing.T) { + psc := newProxySiteClient(t, createOperatingSystemMethod, nil, nil) + f.bindProxyClient(psc) + + createReq := model.APIOperatingSystemCreateRequest{ + Name: "tmpl-proxy-os", + Description: cutil.GetPtr("templated via proxy"), + IpxeTemplateId: cutil.GetPtr(f.tmpl.ID.String()), + SiteIDs: []string{f.site.ID.String()}, + IpxeTemplateParameters: model.APIOperatingSystemIpxeParameters{ + {Name: "version", Value: "22.04"}, + }, + } + body, merr := json.Marshal(createReq) + require.NoError(t, merr) + + ec, rec := f.newEchoContext(http.MethodPost, string(body), map[string]string{"orgName": f.tnOrg}) + h := CreateOperatingSystemHandler{dbSession: f.dbSession, tc: f.tc, scp: f.scp, cfg: f.cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + psc.client.AssertExpectations(t) + psc.workflow.AssertExpectations(t) + assert.Equal(t, createOperatingSystemMethod, psc.captured.FullMethod) + assert.Empty(t, psc.captured.EncryptedSecrets) + assertProxyCreatePayload(t, f.tnOrg, f.tmpl.ID.String(), psc.captured.RequestJSON, "22.04") + + rsp := &model.APIOperatingSystem{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), rsp)) + require.NotNil(t, rsp.Type) + assert.Equal(t, cdbm.OperatingSystemTypeTemplatedIPXE, *rsp.Type) + assert.Equal(t, cdbm.OperatingSystemStatusReady, rsp.Status) + require.Len(t, rsp.SiteAssociations, 1) + require.NotNil(t, rsp.SiteAssociations[0].Site) + assert.Equal(t, f.site.ID.String(), rsp.SiteAssociations[0].Site.ID) + 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") + + psc := newProxySiteClient(t, updateOperatingSystemMethod, nil, nil) + f.bindProxyClient(psc) + + updateReq := model.APIOperatingSystemUpdateRequest{ + Description: cutil.GetPtr("templated via proxy - updated"), + IpxeTemplateParameters: &model.APIOperatingSystemIpxeParameters{ + {Name: "version", Value: "24.04"}, + }, + } + body, merr := json.Marshal(updateReq) + require.NoError(t, merr) + + ec, rec := f.newEchoContext(http.MethodPatch, string(body), map[string]string{"orgName": f.tnOrg, "id": osID}) + h := UpdateOperatingSystemHandler{dbSession: f.dbSession, tc: f.tc, scp: f.scp, cfg: f.cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + psc.client.AssertExpectations(t) + psc.workflow.AssertExpectations(t) + assert.Equal(t, updateOperatingSystemMethod, psc.captured.FullMethod) + assertProxyUpdatePayload(t, osID, f.tmpl.ID.String(), psc.captured.RequestJSON, "24.04") + + 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) + assert.Equal(t, cdbm.OperatingSystemStatusReady, rsp.Status) + require.Len(t, rsp.SiteAssociations, 1) + assert.Equal(t, cdbm.OperatingSystemSiteAssociationStatusSynced, rsp.SiteAssociations[0].Status) + }) + + 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") + + psc := newProxySiteClient(t, deleteOperatingSystemMethod, nil, nil) + f.bindProxyClient(psc) + + ec, rec := f.newEchoContext(http.MethodDelete, "", map[string]string{"orgName": f.tnOrg, "id": osID}) + h := DeleteOperatingSystemHandler{dbSession: f.dbSession, tc: f.tc, scp: f.scp, cfg: f.cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusAccepted, rec.Code, "body: %s", rec.Body.String()) + + psc.client.AssertExpectations(t) + psc.workflow.AssertExpectations(t) + assert.Equal(t, deleteOperatingSystemMethod, psc.captured.FullMethod) + assertProxyDeletePayload(t, osID, psc.captured.RequestJSON) + + parsedID, perr := uuid.Parse(osID) + require.NoError(t, perr) + osDAO := cdbm.NewOperatingSystemDAO(f.dbSession) + _, gerr := osDAO.GetByID(f.ctx, nil, parsedID, nil) + assert.ErrorIs(t, gerr, cdb.ErrDoesNotExist, "OS should be soft-deleted once every site is cleaned up") + }) +} + +func TestOperatingSystemHandler_TemplatedIPXE_ProxyCreateExecuteError(t *testing.T) { + f := buildTemplatedProxyFixture(t) + + psc := newProxySiteClient(t, createOperatingSystemMethod, nil, errors.New("workflow start failed")) + f.bindProxyClient(psc) + + createReq := model.APIOperatingSystemCreateRequest{ + Name: "tmpl-proxy-os-error", + IpxeTemplateId: cutil.GetPtr(f.tmpl.ID.String()), + SiteIDs: []string{f.site.ID.String()}, + } + body, err := json.Marshal(createReq) + require.NoError(t, err) + + ec, rec := f.newEchoContext(http.MethodPost, string(body), map[string]string{"orgName": f.tnOrg}) + h := CreateOperatingSystemHandler{dbSession: f.dbSession, tc: f.tc, scp: f.scp, cfg: f.cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + psc.client.AssertExpectations(t) + + rsp := &model.APIOperatingSystem{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), rsp)) + assert.Equal(t, cdbm.OperatingSystemStatusError, rsp.Status) + require.Len(t, rsp.SiteAssociations, 1) + assert.Equal(t, cdbm.OperatingSystemSiteAssociationStatusError, rsp.SiteAssociations[0].Status) + + parsedID, perr := uuid.Parse(rsp.ID) + require.NoError(t, perr) + assert.Equal(t, cdbm.OperatingSystemStatusError, f.osStatus(t, parsedID)) + assert.Equal(t, cdbm.OperatingSystemSiteAssociationStatusError, f.osAssociationStatus(t, parsedID)) +} + +func TestOperatingSystemHandler_TemplatedIPXE_ProxyCreateTimeout(t *testing.T) { + f := buildTemplatedProxyFixture(t) + + timeoutErr := tp.NewTimeoutError(enums.TIMEOUT_TYPE_UNSPECIFIED, nil, nil) + psc := newProxySiteClient(t, createOperatingSystemMethod, timeoutErr, nil) + f.bindProxyClient(psc) + + createReq := model.APIOperatingSystemCreateRequest{ + Name: "tmpl-proxy-os-timeout", + IpxeTemplateId: cutil.GetPtr(f.tmpl.ID.String()), + SiteIDs: []string{f.site.ID.String()}, + } + body, err := json.Marshal(createReq) + require.NoError(t, err) + + ec, rec := f.newEchoContext(http.MethodPost, string(body), map[string]string{"orgName": f.tnOrg}) + h := CreateOperatingSystemHandler{dbSession: f.dbSession, tc: f.tc, scp: f.scp, cfg: f.cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + psc.client.AssertExpectations(t) + psc.workflow.AssertExpectations(t) + assert.Equal(t, createOperatingSystemMethod, psc.captured.FullMethod) + + rsp := &model.APIOperatingSystem{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), rsp)) + assert.Equal(t, cdbm.OperatingSystemStatusError, rsp.Status) + require.Len(t, rsp.SiteAssociations, 1) + assert.Equal(t, cdbm.OperatingSystemSiteAssociationStatusError, rsp.SiteAssociations[0].Status) + + parsedID, perr := uuid.Parse(rsp.ID) + require.NoError(t, perr) + assert.Equal(t, cdbm.OperatingSystemStatusError, f.osStatus(t, parsedID)) + assert.Equal(t, cdbm.OperatingSystemSiteAssociationStatusError, f.osAssociationStatus(t, parsedID)) +} + +// TestOperatingSystemHandler_TemplatedIPXE_TemplateNotAvailableAtSite verifies that +// creating a Templated iPXE Operating System whose referenced iPXE template has no +// IpxeTemplateSiteAssociation at the target Site is rejected up front (before any +// record is persisted or pushed), since the definition could never render there. +func TestOperatingSystemHandler_TemplatedIPXE_TemplateNotAvailableAtSite(t *testing.T) { + f := buildTemplatedProxyFixture(t) + + // A template that exists but has no association at the tenant's (only) Site. + templateDAO := cdbm.NewIpxeTemplateDAO(f.dbSession) + orphanTmpl, err := templateDAO.Create(f.ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "tmpl-proxy-template-no-itsa", + Template: "#!ipxe\n", + Visibility: "Public", + }) + require.NoError(t, err) + + createReq := model.APIOperatingSystemCreateRequest{ + Name: "tmpl-proxy-os-no-itsa", + IpxeTemplateId: cutil.GetPtr(orphanTmpl.ID.String()), + SiteIDs: []string{f.site.ID.String()}, + } + body, merr := json.Marshal(createReq) + require.NoError(t, merr) + + ec, rec := f.newEchoContext(http.MethodPost, string(body), map[string]string{"orgName": f.tnOrg}) + h := CreateOperatingSystemHandler{dbSession: f.dbSession, tc: f.tc, scp: f.scp, cfg: f.cfg} + require.NoError(t, h.Handle(ec)) + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "not available at Site") + + // The rejected request must not have persisted an Operating System. + osDAO := cdbm.NewOperatingSystemDAO(f.dbSession) + _, tot, gerr := osDAO.GetAll(f.ctx, nil, + cdbm.OperatingSystemFilterInput{Names: []string{"tmpl-proxy-os-no-itsa"}}, + cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) + require.NoError(t, gerr) + assert.Equal(t, 0, tot) +} 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..08a2fd2882 --- /dev/null +++ b/rest-api/api/pkg/api/model/ipxetemplate.go @@ -0,0 +1,74 @@ +// 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"` +} + +// APIIpxeTemplateGetAllRequest captures optional list filters for GET /ipxe-template. +type APIIpxeTemplateGetAllRequest struct { + SiteIDs []string `query:"siteId"` +} + +// 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..a6b52f91b8 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" @@ -32,6 +34,194 @@ func IsCloudInitFromUserData(userData *string) bool { return userData != nil && *userData != "" } +// APIOperatingSystemIpxeParameter is the API representation of a single iPXE +// template name/value parameter. It is API-owned so the REST contract does not +// depend on the persistence model. +type APIOperatingSystemIpxeParameter struct { + // Name is the parameter name (used as a variable in the template). + Name string `json:"name"` + // Value is the parameter value. + Value string `json:"value"` +} + +// toDBModel converts the receiver to the persistence model. +func (p APIOperatingSystemIpxeParameter) toDBModel() cdbm.OperatingSystemIpxeParameter { + return cdbm.OperatingSystemIpxeParameter{Name: p.Name, Value: p.Value} +} + +// APIOperatingSystemIpxeParameters is a typed list of iPXE parameters carrying +// list-level validation and conversion helpers. +type APIOperatingSystemIpxeParameters []APIOperatingSystemIpxeParameter + +// Validate checks every parameter in the list. +func (ps APIOperatingSystemIpxeParameters) Validate() error { + for i, p := range ps { + if strings.TrimSpace(p.Name) == "" { + return validation.Errors{"ipxeTemplateParameters": fmt.Errorf("entry %d: name is required", i)} + } + } + return nil +} + +// ToDBModel converts the list to the persistence model, preserving nil. +func (ps APIOperatingSystemIpxeParameters) ToDBModel() []cdbm.OperatingSystemIpxeParameter { + if ps == nil { + return nil + } + out := make([]cdbm.OperatingSystemIpxeParameter, len(ps)) + for i := range ps { + out[i] = ps[i].toDBModel() + } + return out +} + +// ToDBModelPtr converts an optional (pointer) list to the pointer persistence +// model used by update inputs, preserving a nil pointer (field not provided). +func (ps *APIOperatingSystemIpxeParameters) ToDBModelPtr() *[]cdbm.OperatingSystemIpxeParameter { + if ps == nil { + return nil + } + out := ps.ToDBModel() + return &out +} + +// APIOperatingSystemIpxeArtifact is the API (request) representation of a single +// iPXE artifact (kernel, initrd, ISO, ...). AuthToken is accepted on input but is +// never echoed back: responses use APIOperatingSystemIpxeArtifactResponse, which +// has no AuthToken field. +type APIOperatingSystemIpxeArtifact struct { + // Name is the artifact name. + Name string `json:"name"` + // URL is the original URL for the artifact. + URL string `json:"url"` + // SHA is an optional SHA256 checksum. + SHA *string `json:"sha"` + // AuthType is an optional auth type (Basic or Bearer). + AuthType *string `json:"authType"` + // AuthToken is an optional auth token, only accepted on input. + AuthToken *string `json:"authToken"` + // CacheStrategy controls how the artifact is cached on-site. + CacheStrategy string `json:"cacheStrategy"` +} + +// toDBModel converts the receiver to the persistence model. +func (a APIOperatingSystemIpxeArtifact) toDBModel() cdbm.OperatingSystemIpxeArtifact { + return cdbm.OperatingSystemIpxeArtifact{ + Name: a.Name, + URL: a.URL, + SHA: a.SHA, + AuthType: a.AuthType, + AuthToken: a.AuthToken, + CacheStrategy: a.CacheStrategy, + } +} + +// APIOperatingSystemIpxeArtifacts is a typed list of iPXE artifacts carrying +// list-level validation and conversion helpers. +type APIOperatingSystemIpxeArtifacts []APIOperatingSystemIpxeArtifact + +// Validate checks every artifact in the list. +func (as APIOperatingSystemIpxeArtifacts) Validate() error { + for i, a := range as { + 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 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)} + } + 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 +} + +// ToDBModel converts the list to the persistence model, preserving nil. +func (as APIOperatingSystemIpxeArtifacts) ToDBModel() []cdbm.OperatingSystemIpxeArtifact { + if as == nil { + return nil + } + out := make([]cdbm.OperatingSystemIpxeArtifact, len(as)) + for i := range as { + out[i] = as[i].toDBModel() + } + return out +} + +// ToDBModelPtr converts an optional (pointer) list to the pointer persistence +// model used by update inputs, preserving a nil pointer (field not provided). +func (as *APIOperatingSystemIpxeArtifacts) ToDBModelPtr() *[]cdbm.OperatingSystemIpxeArtifact { + if as == nil { + return nil + } + out := as.ToDBModel() + return &out +} + +// APIOperatingSystemIpxeArtifactResponse is the API response representation of an +// iPXE artifact. It deliberately has no AuthToken field so stored secrets cannot +// be serialized back to clients (structural redaction). +type APIOperatingSystemIpxeArtifactResponse struct { + // Name is the artifact name. + Name string `json:"name"` + // URL is the original URL for the artifact. + URL string `json:"url"` + // SHA is an optional SHA256 checksum. + SHA *string `json:"sha"` + // AuthType is an optional auth type (Basic or Bearer). + AuthType *string `json:"authType"` + // CacheStrategy controls how the artifact is cached on-site. + CacheStrategy string `json:"cacheStrategy"` +} + +// newAPIIpxeParametersFromDB converts persisted parameters to the API response +// representation, preserving nil. +func newAPIIpxeParametersFromDB(params []cdbm.OperatingSystemIpxeParameter) []APIOperatingSystemIpxeParameter { + if params == nil { + return nil + } + out := make([]APIOperatingSystemIpxeParameter, len(params)) + for i, p := range params { + out[i] = APIOperatingSystemIpxeParameter{Name: p.Name, Value: p.Value} + } + return out +} + +// newAPIIpxeArtifactResponsesFromDB converts persisted artifacts to the API +// response representation, preserving nil. AuthToken is dropped structurally: the +// response type has no such field. +func newAPIIpxeArtifactResponsesFromDB(artifacts []cdbm.OperatingSystemIpxeArtifact) []APIOperatingSystemIpxeArtifactResponse { + if artifacts == nil { + return nil + } + out := make([]APIOperatingSystemIpxeArtifactResponse, len(artifacts)) + for i, a := range artifacts { + out[i] = APIOperatingSystemIpxeArtifactResponse{ + Name: a.Name, + URL: a.URL, + SHA: a.SHA, + AuthType: a.AuthType, + CacheStrategy: a.CacheStrategy, + } + } + return out +} + // APIOperatingSystemCreateRequest is the data structure to capture user request to create a new OperatingSystem type APIOperatingSystemCreateRequest struct { // Name is the name of the OperatingSystem @@ -70,6 +260,24 @@ 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 APIOperatingSystemIpxeParameters `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition + IpxeTemplateArtifacts APIOperatingSystemIpxeArtifacts `json:"ipxeTemplateArtifacts"` +} + +// 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 +296,25 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { return err } + if oscr.IpxeTemplateId != nil { + if strings.TrimSpace(*oscr.IpxeTemplateId) == "" { + return validation.Errors{ + "ipxeTemplateId": errors.New("must not be empty"), + } + } + if _, err := uuid.Parse(*oscr.IpxeTemplateId); err != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("must be a valid UUID"), + } + } + } + + 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 +322,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"), + "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 +338,28 @@ 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"), + } + } + } + + // 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 checks below. Raw iPXE and Image types have no further type-specific + // pre-checks here. + if oscr.IpxeTemplateId != nil { + return oscr.validateTemplatedIpxeOS() + } + if oscr.ImageURL != nil { err = validation.ValidateStruct(oscr, validation.Field(&oscr.ImageURL, is.URL), @@ -284,6 +533,12 @@ 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 *APIOperatingSystemIpxeParameters `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition + IpxeTemplateArtifacts *APIOperatingSystemIpxeArtifacts `json:"ipxeTemplateArtifacts"` } // Validate ensure the values passed in request are acceptable @@ -319,9 +574,73 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating } } + // 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 osur.IpxeTemplateId != nil { + if strings.TrimSpace(*osur.IpxeTemplateId) == "" { + return validation.Errors{"ipxeTemplateId": errors.New("must not be empty")} + } + if _, err := uuid.Parse(*osur.IpxeTemplateId); err != nil { + return validation.Errors{"ipxeTemplateId": errors.New("must be a valid UUID")} + } + } + } + if existingOS.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + if osur.IpxeTemplateParameters != nil { + if verr := osur.IpxeTemplateParameters.Validate(); verr != nil { + return verr + } + } + if osur.IpxeTemplateArtifacts != nil { + if verr := osur.IpxeTemplateArtifacts.Validate(); 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"), + "imageUrl": errors.New("cannot be specified for iPXE based Operating Systems"), } } @@ -330,7 +649,7 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating // verify if os was not created as image-based, reject the update if imageURL provided if !isImageBased && osur.ImageURL != nil { return validation.Errors{ - "imageURL": errors.New("unable to set image URL for non-image based Operating System"), + "imageUrl": errors.New("unable to set image URL for non-image based Operating System"), } } else if isImageBased && osur.IpxeScript != nil { return validation.Errors{ @@ -597,6 +916,13 @@ 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 []APIOperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition. + // The response artifact type has no authToken field, so stored secrets are never echoed back. + IpxeTemplateArtifacts []APIOperatingSystemIpxeArtifactResponse `json:"ipxeTemplateArtifacts"` // 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 +965,7 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail RootFsID: dbOS.RootFsID, RootFsLabel: dbOS.RootFsLabel, IpxeScript: dbOS.IpxeScript, + IpxeTemplateId: dbOS.IpxeTemplateId, PhoneHomeEnabled: dbOS.PhoneHomeEnabled, UserData: dbOS.UserData, IsCloudInit: IsCloudInitFromUserData(dbOS.UserData), @@ -650,6 +977,10 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail Created: dbOS.Created, Updated: dbOS.Updated, } + apiOperatingSystem.IpxeTemplateParameters = newAPIIpxeParametersFromDB(dbOS.IpxeTemplateParameters) + // The response artifact type has no AuthToken field, so stored secrets are + // dropped structurally rather than by manual redaction. + apiOperatingSystem.IpxeTemplateArtifacts = newAPIIpxeArtifactResponsesFromDB(dbOS.IpxeTemplateArtifacts) if dbOS.InfrastructureProviderID != nil { apiOperatingSystem.InfrastructureProviderID = cutil.GetPtr(dbOS.InfrastructureProviderID.String()) } @@ -699,3 +1030,131 @@ func NewAPIOperatingSystemSummary(dbos *cdbm.OperatingSystem) *APIOperatingSyste return &aos } + +// validateTemplatedIpxeOS fully validates a Templated iPXE create request: image +// fields must be absent, at least one target site must be specified (the site list +// is fixed at creation and is immutable thereafter), 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 len(oscr.SiteIDs) == 0 { + return validation.Errors{"siteIds": errors.New("at least one siteId must be specified for Templated iPXE Operating Systems")} + } + for _, siteID := range oscr.SiteIDs { + if _, err := uuid.Parse(siteID); err != nil { + return validation.Errors{"siteIds": fmt.Errorf("siteId %q is not a valid UUID", siteID)} + } + } + + if err := oscr.IpxeTemplateParameters.Validate(); err != nil { + return err + } + if err := oscr.IpxeTemplateArtifacts.Validate(); 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 +}() + +// 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: tenantOrganizationIDProto(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()}, + } +} + +// tenantOrganizationIDProto maps a persisted org string onto the optional Core +// field. Empty means provider-owned and must be omitted (Core rejects ""). +func tenantOrganizationIDProto(org string) *string { + if org == "" { + return nil + } + return &org +} + +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..ed4f7278ea --- /dev/null +++ b/rest-api/api/pkg/api/model/operatingsystem_templated_test.go @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "encoding/json" + "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_Templated(t *testing.T) { + tmplID := cutil.GetPtr(uuid.NewString()) + siteIDs := []string{uuid.NewString()} + tests := []struct { + desc string + obj APIOperatingSystemCreateRequest + expectErr bool + }{ + { + desc: "templated requires at least one siteId", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID}, + expectErr: true, + }, + { + desc: "templated with siteIds is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, SiteIDs: siteIDs}, + expectErr: false, + }, + { + desc: "templated with non-UUID ipxeTemplateId is rejected", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: cutil.GetPtr("not-a-uuid"), SiteIDs: siteIDs}, + expectErr: true, + }, + { + desc: "templated with non-UUID siteId is rejected", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, SiteIDs: []string{"not-a-uuid"}}, + expectErr: true, + }, + { + desc: "templated artifact with valid cache strategy is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, SiteIDs: siteIDs, IpxeTemplateArtifacts: APIOperatingSystemIpxeArtifacts{{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, SiteIDs: siteIDs, IpxeTemplateArtifacts: APIOperatingSystemIpxeArtifacts{{Name: "kernel", URL: "http://x/k", CacheStrategy: "BOGUS"}}}, + expectErr: true, + }, + { + desc: "templated artifact missing url is rejected", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, SiteIDs: siteIDs, IpxeTemplateArtifacts: APIOperatingSystemIpxeArtifacts{{Name: "kernel", CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}}}, + expectErr: true, + }, + { + desc: "raw ipxe rejects template parameters", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), IpxeTemplateParameters: APIOperatingSystemIpxeParameters{{Name: "p", Value: "v"}}}, + expectErr: true, + }, + { + desc: "raw ipxe rejects siteIds", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), SiteIDs: siteIDs}, + expectErr: true, + }, + { + desc: "raw ipxe without siteIds is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe")}, + expectErr: false, + }, + { + desc: "ipxeScript and ipxeTemplateId mutually exclusive", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), IpxeTemplateId: tmplID}, + expectErr: true, + }, + { + desc: "image with siteId is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", ImageURL: cutil.GetPtr("http://iso.net/iso"), ImageSHA: cutil.GetPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), RootFsID: cutil.GetPtr("666c2eee-193d-42db-a490-4c444342bd4e"), SiteIDs: siteIDs}, + expectErr: false, + }, + } + 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_Template(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("templated accepts template params", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &APIOperatingSystemIpxeParameters{{Name: "p", Value: "v"}}}).Validate(templatedOS) + assert.NoError(t, err) + }) + t.Run("raw ipxe rejects template params", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &APIOperatingSystemIpxeParameters{{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) + }) + t.Run("templated accepts valid UUID template id", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateId: cutil.GetPtr(uuid.NewString())}).Validate(templatedOS) + assert.NoError(t, err) + }) + t.Run("templated rejects non-UUID template id", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateId: cutil.GetPtr("not-a-uuid")}).Validate(templatedOS) + 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.GetTenantOrganizationId()) + 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"), + 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.Len(t, api.IpxeTemplateArtifacts, 1) + assert.Equal(t, "kernel", api.IpxeTemplateArtifacts[0].Name) + + // The response artifact type has no AuthToken field, so the serialized + // response can never carry the stored secret (structural redaction). + blob, err := json.Marshal(api) + require.NoError(t, err) + assert.NotContains(t, string(blob), "authToken", "serialized response must not contain an authToken field") + assert.NotContains(t, string(blob), "super-secret", "serialized response must not contain the stored secret") + + // The source DB object must not be mutated when building the response. + 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..16bc471240 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, @@ -127,6 +128,10 @@ func TestNewAPIRoutes(t *testing.T) { assertRouteExists(t, got, http.MethodPost, expectedMachineBatchPath) assertRouteExists(t, got, http.MethodPatch, expectedMachineBatchPath) assertRouteBefore(t, got, http.MethodPatch, expectedMachineBatchPath, http.MethodPatch, "/org/:orgName/"+cfg.GetAPIName()+"/expected-machine/:id") + + ipxeTemplatePath := "/org/:orgName/" + cfg.GetAPIName() + "/ipxe-template" + assertRouteExists(t, got, http.MethodGet, ipxeTemplatePath) + assertRouteExists(t, got, http.MethodGet, ipxeTemplatePath+"/:id") }) } } diff --git a/rest-api/db/pkg/db/model/operatingsystem.go b/rest-api/db/pkg/db/model/operatingsystem.go index b2e0fdf849..997fbad607 100644 --- a/rest-api/db/pkg/db/model/operatingsystem.go +++ b/rest-api/db/pkg/db/model/operatingsystem.go @@ -44,13 +44,6 @@ const ( // OperatingSystemTypeImage is the image based OperatingSystem type OperatingSystemTypeImage = "Image" - // OperatingSystemScopeLocal means single site, bidirectional sync (provider-owned OS from nico-core). - OperatingSystemScopeLocal = "Local" - // OperatingSystemScopeLimited means carbide-rest is the source of truth for a fixed list of sites. - OperatingSystemScopeLimited = "Limited" - // OperatingSystemScopeGlobal means carbide-rest is the source of truth for all owner sites. - OperatingSystemScopeGlobal = "Global" - // OperatingSystemOrderByDefault default field to be used for ordering when none specified OperatingSystemOrderByDefault = "created" @@ -236,22 +229,17 @@ type OperatingSystem struct { IpxeTemplateParameters []OperatingSystemIpxeParameter `bun:"ipxe_template_parameters,type:jsonb"` IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `bun:"ipxe_template_artifacts,type:jsonb"` IpxeTemplateDefinitionHash *string `bun:"ipxe_template_definition_hash"` - // IpxeOsScope controls synchronization direction between carbide-rest and nico-core for - // iPXE OS definitions: "Local" is bidirectional/provider-owned from nico-core, while - // "Global" and "Limited" make carbide-rest the source of truth. nil for Image-type OS; - // legacy iPXE rows with nil scope are treated as "Local". - IpxeOsScope *string `bun:"ipxe_os_scope"` - UserData *string `bun:"user_data"` - AllowOverride bool `bun:"allow_override,notnull"` - EnableBlockStorage bool `bun:"enable_block_storage,notnull"` - PhoneHomeEnabled bool `bun:"phone_home_enabled,notnull"` - IsActive bool `bun:"is_active,notnull"` - DeactivationNote *string `bun:"deactivation_note"` // Note for deactivation, if any - Status string `bun:"status,notnull"` - Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` - Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` - Deleted *time.Time `bun:"deleted,soft_delete"` - CreatedBy uuid.UUID `bun:"type:uuid,notnull"` + UserData *string `bun:"user_data"` + AllowOverride bool `bun:"allow_override,notnull"` + EnableBlockStorage bool `bun:"enable_block_storage,notnull"` + PhoneHomeEnabled bool `bun:"phone_home_enabled,notnull"` + IsActive bool `bun:"is_active,notnull"` + DeactivationNote *string `bun:"deactivation_note"` // Note for deactivation, if any + Status string `bun:"status,notnull"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` + Deleted *time.Time `bun:"deleted,soft_delete"` + CreatedBy uuid.UUID `bun:"type:uuid,notnull"` } // GetSiteID returns the OperatingSystem ID to use when communicating @@ -330,7 +318,6 @@ type OperatingSystemCreateInput struct { IpxeTemplateParameters []OperatingSystemIpxeParameter IpxeTemplateArtifacts []OperatingSystemIpxeArtifact IpxeOSHash *string - IpxeOsScope *string UserData *string AllowOverride bool EnableBlockStorage bool @@ -344,8 +331,8 @@ type OperatingSystemCreateInput struct { // template reference, template parameters, artifacts and definition hash. // // Ownership and sync-context fields (ID, Org, InfrastructureProviderID, TenantID, -// IpxeOsScope, CreatedBy and the image-* fields) are not carried on this proto and -// must be set by the caller after calling FromProto. A nil proto is a no-op. +// CreatedBy and the image-* fields) are not carried on this proto and must be set +// by the caller after calling FromProto. A nil proto is a no-op. func (in *OperatingSystemCreateInput) FromProto(protoOS *corev1.OperatingSystem) { if protoOS == nil { return @@ -412,7 +399,6 @@ type OperatingSystemUpdateInput struct { IpxeTemplateParameters *[]OperatingSystemIpxeParameter IpxeTemplateArtifacts *[]OperatingSystemIpxeArtifact IpxeOSHash *string - Scope *string UserData *string AllowOverride *bool EnableBlockStorage *bool @@ -445,7 +431,6 @@ type OperatingSystemClearInput struct { IpxeTemplateParameters bool IpxeTemplateArtifacts bool IpxeOSHash bool - Scope bool } type OperatingSystemFilterInput struct { @@ -459,8 +444,6 @@ type OperatingSystemFilterInput struct { SearchQuery *string OperatingSystemIds []uuid.UUID IsActive *bool - // Scopes filters iPXE OS definitions by their scope (e.g. "Global", "Limited", "Local"). - Scopes []string // IncludeDeleted includes soft-deleted records (used by inventory sync to detect deletions). IncludeDeleted bool } @@ -559,7 +542,6 @@ func (ossd OperatingSystemSQLDAO) Create(ctx context.Context, tx *db.Tx, input O IpxeTemplateParameters: input.IpxeTemplateParameters, IpxeTemplateArtifacts: input.IpxeTemplateArtifacts, IpxeTemplateDefinitionHash: input.IpxeOSHash, - IpxeOsScope: input.IpxeOsScope, } _, err := db.GetIDB(tx, ossd.dbSession).NewInsert().Model(os).Exec(ctx) @@ -675,17 +657,6 @@ func (ossd OperatingSystemSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter query = query.Where("os.is_active = ?", *filter.IsActive) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "is_active", *filter.IsActive) } - if filter.Scopes != nil { - // Scope only applies to iPXE OS rows; restrict the match to iPXE types so Image rows - // (which have a NULL scope) are not coerced to "Local" by the COALESCE. - query = query.Where( - "os.type IN (?) AND COALESCE(os.ipxe_os_scope, ?) IN (?)", - bun.In([]string{OperatingSystemTypeIPXE, OperatingSystemTypeTemplatedIPXE}), - OperatingSystemScopeLocal, - bun.In(filter.Scopes), - ) - ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "scopes", filter.Scopes) - } if filter.IncludeDeleted { query = query.WhereAllWithDeleted() } @@ -861,10 +832,6 @@ func (ossd OperatingSystemSQLDAO) Update(ctx context.Context, tx *db.Tx, input O it.IpxeTemplateDefinitionHash = input.IpxeOSHash updatedFields = append(updatedFields, "ipxe_template_definition_hash") } - if input.Scope != nil { - it.IpxeOsScope = input.Scope - updatedFields = append(updatedFields, "ipxe_os_scope") - } if len(updatedFields) > 0 { updatedFields = append(updatedFields, "updated") @@ -977,10 +944,6 @@ func (ossd OperatingSystemSQLDAO) Clear(ctx context.Context, tx *db.Tx, input Op it.IpxeTemplateDefinitionHash = nil updatedFields = append(updatedFields, "ipxe_template_definition_hash") } - if input.Scope { - it.IpxeOsScope = nil - updatedFields = append(updatedFields, "ipxe_os_scope") - } if len(updatedFields) > 0 { updatedFields = append(updatedFields, "updated") diff --git a/rest-api/db/pkg/db/model/operatingsystem_ipxe_test.go b/rest-api/db/pkg/db/model/operatingsystem_ipxe_test.go index 18b2918659..e9680717e6 100644 --- a/rest-api/db/pkg/db/model/operatingsystem_ipxe_test.go +++ b/rest-api/db/pkg/db/model/operatingsystem_ipxe_test.go @@ -8,15 +8,14 @@ import ( "testing" cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" - "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestOperatingSystemSQLDAO_TemplatedIPXERoundTrip exercises the iPXE template definition -// columns and the ipxe_os_scope column added for the Templated iPXE OS variant: create, -// read-back of the JSONB parameter/artifact slices, scope update, and scope/iPXE clear. +// columns added for the Templated iPXE OS variant: create, read-back of the JSONB +// parameter/artifact slices, artifact update, and iPXE definition clear. func TestOperatingSystemSQLDAO_TemplatedIPXERoundTrip(t *testing.T) { ctx := context.Background() dbSession := testOperatingSystemInitDB(t) @@ -46,10 +45,9 @@ func TestOperatingSystemSQLDAO_TemplatedIPXERoundTrip(t *testing.T) { CacheStrategy: OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded, }, }, - IpxeOSHash: cutil.GetPtr("hash-1"), - IpxeOsScope: cutil.GetPtr(OperatingSystemScopeGlobal), - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + IpxeOSHash: cutil.GetPtr("hash-1"), + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) require.NoError(t, err) require.NotNil(t, created) @@ -57,8 +55,6 @@ func TestOperatingSystemSQLDAO_TemplatedIPXERoundTrip(t *testing.T) { got, err := dao.GetByID(ctx, nil, created.ID, nil) require.NoError(t, err) assert.Equal(t, OperatingSystemTypeTemplatedIPXE, got.Type) - require.NotNil(t, got.IpxeOsScope) - assert.Equal(t, OperatingSystemScopeGlobal, *got.IpxeOsScope) require.NotNil(t, got.IpxeTemplateId) assert.Equal(t, templateID, *got.IpxeTemplateId) require.NotNil(t, got.IpxeTemplateDefinitionHash) @@ -74,78 +70,29 @@ func TestOperatingSystemSQLDAO_TemplatedIPXERoundTrip(t *testing.T) { assert.Equal(t, "secret-token", *got.IpxeTemplateArtifacts[0].AuthToken) assert.Equal(t, OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded, got.IpxeTemplateArtifacts[0].CacheStrategy) - // Update scope and artifacts. + // Update artifacts. updated, err := dao.Update(ctx, nil, OperatingSystemUpdateInput{ OperatingSystemId: created.ID, - Scope: cutil.GetPtr(OperatingSystemScopeLimited), IpxeTemplateArtifacts: &[]OperatingSystemIpxeArtifact{ {Name: "initrd", URL: "https://example.test/initrd", CacheStrategy: OperatingSystemIpxeArtifactCacheStrategyCachedOnly}, }, }) require.NoError(t, err) - require.NotNil(t, updated.IpxeOsScope) - assert.Equal(t, OperatingSystemScopeLimited, *updated.IpxeOsScope) require.Len(t, updated.IpxeTemplateArtifacts, 1) assert.Equal(t, "initrd", updated.IpxeTemplateArtifacts[0].Name) assert.Equal(t, OperatingSystemIpxeArtifactCacheStrategyCachedOnly, updated.IpxeTemplateArtifacts[0].CacheStrategy) - // Clear the iPXE definition and scope. + // Clear the iPXE definition. cleared, err := dao.Clear(ctx, nil, OperatingSystemClearInput{ OperatingSystemId: created.ID, IpxeTemplateId: true, IpxeTemplateParameters: true, IpxeTemplateArtifacts: true, IpxeOSHash: true, - Scope: true, }) require.NoError(t, err) - assert.Nil(t, cleared.IpxeOsScope) assert.Nil(t, cleared.IpxeTemplateId) assert.Nil(t, cleared.IpxeTemplateParameters) assert.Nil(t, cleared.IpxeTemplateArtifacts) assert.Nil(t, cleared.IpxeTemplateDefinitionHash) } - -// TestOperatingSystemSQLDAO_ScopeFilter verifies the Scopes filter only matches iPXE OS rows -// and never coerces Image rows (NULL scope) into the "Local" bucket. -func TestOperatingSystemSQLDAO_ScopeFilter(t *testing.T) { - ctx := context.Background() - dbSession := testOperatingSystemInitDB(t) - defer dbSession.Close() - testOperatingSystemSetupSchema(t, dbSession) - - ip := testOperatingSystemBuildInfrastructureProvider(t, dbSession, "testIP") - tenant := testOperatingSystemBuildTenant(t, dbSession, "testTenant") - user := testOperatingSystemBuildUser(t, dbSession, "testUser") - - dao := NewOperatingSystemDAO(dbSession) - - // Global-scoped Templated iPXE OS. - _, err := dao.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "global-ipxe", Org: "test", TenantID: &tenant.ID, - OsType: OperatingSystemTypeTemplatedIPXE, IpxeOsScope: cutil.GetPtr(OperatingSystemScopeGlobal), - Status: OperatingSystemStatusReady, CreatedBy: user.ID, - }) - require.NoError(t, err) - - // Raw iPXE OS with no explicit scope (treated as Local via COALESCE). - _, err = dao.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "local-ipxe", Org: "test", TenantID: &tenant.ID, - OsType: OperatingSystemTypeIPXE, - Status: OperatingSystemStatusReady, CreatedBy: user.ID, - }) - require.NoError(t, err) - - // Image OS: scope does not apply (NULL scope) and must never match a scope filter. - _ = testBuildImageOperatingSystem(t, dbSession, "image-os", cutil.GetPtr("img"), "test", &ip.ID, &tenant.ID, nil, false, OperatingSystemStatusReady, user.ID) - - globalRows, _, err := dao.GetAll(ctx, nil, OperatingSystemFilterInput{Scopes: []string{OperatingSystemScopeGlobal}}, paginator.PageInput{}, nil) - require.NoError(t, err) - assert.Len(t, globalRows, 1) - assert.Equal(t, "global-ipxe", globalRows[0].Name) - - localRows, _, err := dao.GetAll(ctx, nil, OperatingSystemFilterInput{Scopes: []string{OperatingSystemScopeLocal}}, paginator.PageInput{}, nil) - require.NoError(t, err) - assert.Len(t, localRows, 1) - assert.Equal(t, "local-ipxe", localRows[0].Name) -} diff --git a/rest-api/db/pkg/migrations/20260717190000_drop_operating_system_ipxe_os_scope.go b/rest-api/db/pkg/migrations/20260717190000_drop_operating_system_ipxe_os_scope.go new file mode 100644 index 0000000000..d195f11ca1 --- /dev/null +++ b/rest-api/db/pkg/migrations/20260717190000_drop_operating_system_ipxe_os_scope.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/uptrace/bun" +) + +// Drops the operating_system.ipxe_os_scope column. The Local/Global/Limited scope +// concept is removed: synchronization direction for Templated iPXE OS definitions is +// now derived from the number of associated sites (a single site is bidirectional / +// core-driven, more than one makes carbide-rest the source of truth), so the stored +// scope is no longer needed. +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + tx, terr := db.BeginTx(ctx, &sql.TxOptions{}) + if terr != nil { + handlePanic(terr, "failed to begin transaction") + } + + _, err := tx.ExecContext(ctx, `ALTER TABLE operating_system DROP COLUMN IF EXISTS ipxe_os_scope`) + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [up migration] Dropped 'ipxe_os_scope' column from 'operating_system' table successfully. ") + return nil + }, func(ctx context.Context, db *bun.DB) error { + tx, terr := db.BeginTx(ctx, &sql.TxOptions{}) + if terr != nil { + handlePanic(terr, "failed to begin transaction") + } + + // Structural rollback only: the column is restored as a nullable TEXT column. + // The original per-row scope values cannot be recovered. + _, err := tx.ExecContext(ctx, `ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_os_scope TEXT NULL`) + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [down migration] Restored 'ipxe_os_scope' column on 'operating_system' table (values not restored). ") + return nil + }) +} diff --git a/rest-api/docs/index.html b/rest-api/docs/index.html index 35ba52743a..843a051fa3 100644 --- a/rest-api/docs/index.html +++ b/rest-api/docs/index.html @@ -464,7 +464,7 @@
-