diff --git a/rest-api/api/pkg/api/handler/instance.go b/rest-api/api/pkg/api/handler/instance.go index c383632448..7fc3ede6d7 100644 --- a/rest-api/api/pkg/api/handler/instance.go +++ b/rest-api/api/pkg/api/handler/instance.go @@ -195,29 +195,27 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte // Options below should all have been set by the // earlier call to ValidateAndSetOperatingSystemData - - if os.Type == cdbm.OperatingSystemTypeIPXE { - return &cwssaws.InstanceOperatingSystemConfig{ - RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.InstanceOperatingSystemConfig_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *apiRequest.IpxeScript, - }, - }, - UserData: apiRequest.UserData, - }, osID, nil - } else { - return &cwssaws.InstanceOperatingSystemConfig{ - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.InstanceOperatingSystemConfig_OsImageId{ - OsImageId: &cwssaws.UUID{ - Value: os.ID.String(), - }, - }, - UserData: apiRequest.UserData, - }, osID, nil + result := cwssaws.InstanceOperatingSystemConfig{ + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + UserData: apiRequest.UserData, } + switch os.Type { + case cdbm.OperatingSystemTypeIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *apiRequest.IpxeScript}, + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + } + case cdbm.OperatingSystemTypeImage: + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OsImageId{ + OsImageId: &cwssaws.UUID{Value: os.ID.String()}, + } + } + return &result, osID, nil } // Handle godoc @@ -2065,41 +2063,35 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte phoneHomeEnabled = *apiRequest.PhoneHomeEnabled } + result := cwssaws.InstanceOperatingSystemConfig{ + PhoneHomeEnabled: phoneHomeEnabled, + UserData: userData, + } if os != nil { - if os.Type == cdbm.OperatingSystemTypeIPXE { - return &cwssaws.InstanceOperatingSystemConfig{ - RunProvisioningInstructionsOnEveryBoot: alwaysBootWithCustomIpxe, - PhoneHomeEnabled: phoneHomeEnabled, - Variant: &cwssaws.InstanceOperatingSystemConfig_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *ipxeScript, - }, - }, - UserData: userData, - }, osID, nil - } else if os.Type == cdbm.OperatingSystemTypeImage { - return &cwssaws.InstanceOperatingSystemConfig{ - PhoneHomeEnabled: phoneHomeEnabled, - Variant: &cwssaws.InstanceOperatingSystemConfig_OsImageId{ - OsImageId: &cwssaws.UUID{ - Value: os.ID.String(), - }, - }, - UserData: userData, - }, osID, nil + switch os.Type { + case cdbm.OperatingSystemTypeIPXE: + result.RunProvisioningInstructionsOnEveryBoot = alwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *ipxeScript}, + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + result.RunProvisioningInstructionsOnEveryBoot = alwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + } + case cdbm.OperatingSystemTypeImage: + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OsImageId{ + OsImageId: &cwssaws.UUID{Value: os.ID.String()}, + } + } + } else { + result.RunProvisioningInstructionsOnEveryBoot = alwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *ipxeScript}, } } - return &cwssaws.InstanceOperatingSystemConfig{ - RunProvisioningInstructionsOnEveryBoot: alwaysBootWithCustomIpxe, - PhoneHomeEnabled: phoneHomeEnabled, - Variant: &cwssaws.InstanceOperatingSystemConfig_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *ipxeScript, - }, - }, - UserData: userData, - }, osID, nil + return &result, osID, nil } // Handle godoc diff --git a/rest-api/api/pkg/api/handler/instancebatch.go b/rest-api/api/pkg/api/handler/instancebatch.go index e47e33ff2e..fd2bbc3950 100644 --- a/rest-api/api/pkg/api/handler/instancebatch.go +++ b/rest-api/api/pkg/api/handler/instancebatch.go @@ -175,28 +175,27 @@ func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c // Options below should all have been set by the // earlier call to ValidateAndSetOperatingSystemData - if os.Type == cdbm.OperatingSystemTypeIPXE { - return &cwssaws.InstanceOperatingSystemConfig{ - RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.InstanceOperatingSystemConfig_Ipxe{ - Ipxe: &cwssaws.InlineIpxe{ - IpxeScript: *apiRequest.IpxeScript, - }, - }, - UserData: apiRequest.UserData, - }, osID, nil - } else { - return &cwssaws.InstanceOperatingSystemConfig{ - PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, - Variant: &cwssaws.InstanceOperatingSystemConfig_OsImageId{ - OsImageId: &cwssaws.UUID{ - Value: os.ID.String(), - }, - }, - UserData: apiRequest.UserData, - }, osID, nil + result := cwssaws.InstanceOperatingSystemConfig{ + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + UserData: apiRequest.UserData, + } + switch os.Type { + case cdbm.OperatingSystemTypeIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_Ipxe{ + Ipxe: &cwssaws.InlineIpxe{IpxeScript: *apiRequest.IpxeScript}, + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + result.RunProvisioningInstructionsOnEveryBoot = *apiRequest.AlwaysBootWithCustomIpxe + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + } + case cdbm.OperatingSystemTypeImage: + result.Variant = &cwssaws.InstanceOperatingSystemConfig_OsImageId{ + OsImageId: &cwssaws.UUID{Value: os.ID.String()}, + } } + return &result, osID, nil } // Handle godoc 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..f83c7ba4f5 --- /dev/null +++ b/rest-api/api/pkg/api/handler/ipxetemplate.go @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "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" + cerr "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + sutil "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" + 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" +) + +// ~~~~~ 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 *sutil.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: sutil.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("GetAll", "IpxeTemplate", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cerr.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 cerr.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) + } + + // Parse optional siteId query parameters. Multiple values (repeated + // `?siteId=...&siteId=...`) are supported. + requestedSiteIDStrs := c.QueryParams()["siteId"] + requestedSiteIDs := make([]uuid.UUID, 0, len(requestedSiteIDStrs)) + for _, s := range requestedSiteIDStrs { + if s == "" { + continue + } + parsed, perr := uuid.Parse(s) + if perr != nil { + return cerr.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). + // + // Note on tenant-path scoping: tenant access is established per-site via + // `TenantSite` associations (a tenant may be associated with some sites of + // a provider but not others). + 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: sutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites from DB") + return cerr.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: sutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations from DB") + return cerr.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 cerr.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 cerr.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 cerr.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 cerr.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: sutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations from DB") + return cerr.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{IDs: 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 cerr.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 cerr.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 *sutil.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: sutil.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("Get", "IpxeTemplate", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cerr.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 cerr.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 cerr.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 cerr.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 cerr.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: sutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations") + return cerr.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify iPXE template authorization, DB error", nil) + } + + if !callerHasAccessToAnyAssociatedSite(ctx, logger, h.dbSession, infrastructureProvider, tenant, associations) { + logger.Warn().Msg("caller is not authorized to access any Site associated with this iPXE template") + return cerr.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not authorized to access this iPXE template", nil) + } + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, model.NewAPIIpxeTemplate(tmpl)) +} + +// callerHasAccessToAnyAssociatedSite returns true when the caller (provider or tenant) +// has access to at least one site in the given association set. +func callerHasAccessToAnyAssociatedSite( + ctx context.Context, + logger zerolog.Logger, + dbSession *cdb.Session, + provider *cdbm.InfrastructureProvider, + tenant *cdbm.Tenant, + associations []cdbm.IpxeTemplateSiteAssociation, +) bool { + if len(associations) == 0 { + return false + } + + siteIDs := make([]uuid.UUID, 0, len(associations)) + for _, a := range associations { + siteIDs = append(siteIDs, a.SiteID) + } + + // Provider path: any site owned by the caller's provider. + if provider != nil { + siteDAO := cdbm.NewSiteDAO(dbSession) + sites, _, serr := siteDAO.GetAll(ctx, nil, cdbm.SiteFilterInput{ + InfrastructureProviderIDs: []uuid.UUID{provider.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: sutil.GetPtr(1)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites for iPXE template authorization") + return false + } + if len(sites) > 0 { + return true + } + } + + // Tenant path: any site reachable via a TenantSite association. + if tenant != nil { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, terr := tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: sutil.GetPtr(1)}, nil) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations for iPXE template authorization") + return false + } + if len(tss) > 0 { + return true + } + } + + return false +} diff --git a/rest-api/api/pkg/api/handler/ipxetemplate_test.go b/rest-api/api/pkg/api/handler/ipxetemplate_test.go new file mode 100644 index 0000000000..f9ca5f3d11 --- /dev/null +++ b/rest-api/api/pkg/api/handler/ipxetemplate_test.go @@ -0,0 +1,738 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package handler + +import ( + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/api/internal/config" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cdbu "github.com/NVIDIA/infra-controller/rest-api/db/pkg/util" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/uptrace/bun/extra/bundebug" +) + +func testIpxeTemplateInitDB(t *testing.T) *cdb.Session { + dbSession := cdbu.GetTestDBSession(t, false) + dbSession.DB.AddQueryHook(bundebug.NewQueryHook( + bundebug.WithEnabled(false), + bundebug.FromEnv("BUNDEBUG"), + )) + return dbSession +} + +func testIpxeTemplateHandlerSetupSchema(t *testing.T, dbSession *cdb.Session) { + ctx := context.Background() + + // Reset parent tables before any table whose CREATE references them via + // foreign keys. Order: providers first, then sites/tenants, then the + // global ipxe_template table, then the association tables that reference + // ipxe_template/site/tenant. + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.InfrastructureProvider)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.Site)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.Tenant)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.IpxeTemplate)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.IpxeTemplateSiteAssociation)(nil))) + assert.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.TenantSite)(nil))) +} + +type ipxeTemplateTestFixture struct { + ip *cdbm.InfrastructureProvider + site *cdbm.Site + tmpl1 *cdbm.IpxeTemplate + tmpl2 *cdbm.IpxeTemplate +} + +// associateTemplate creates an IpxeTemplateSiteAssociation row linking the +// global template to the given site. +func associateTemplate(t *testing.T, dbSession *cdb.Session, templateID, siteID uuid.UUID) { + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + _, err := itsaDAO.Create(context.Background(), nil, cdbm.IpxeTemplateSiteAssociationCreateInput{ + IpxeTemplateID: templateID, + SiteID: siteID, + }) + assert.Nil(t, err) +} + +func testIpxeTemplateSetupTestData(t *testing.T, dbSession *cdb.Session, org string) *ipxeTemplateTestFixture { + ctx := context.Background() + + ip := &cdbm.InfrastructureProvider{ + ID: uuid.New(), + Name: "test-provider", + Org: org, + } + _, err := dbSession.DB.NewInsert().Model(ip).Exec(ctx) + assert.Nil(t, err) + + site := &cdbm.Site{ + ID: uuid.New(), + Name: "test-site", + Org: org, + InfrastructureProviderID: ip.ID, + Status: cdbm.SiteStatusRegistered, + } + _, err = dbSession.DB.NewInsert().Model(site).Exec(ctx) + assert.Nil(t, err) + + dao := cdbm.NewIpxeTemplateDAO(dbSession) + + tmpl1, err := dao.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "kernel-initrd", Scope: cdbm.IpxeTemplateScopePublic, + RequiredParams: []string{"kernel_params"}, ReservedParams: []string{"base_url"}, RequiredArtifacts: []string{"kernel"}, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, tmpl1.ID, site.ID) + + tmpl2, err := dao.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "ubuntu-autoinstall", Scope: cdbm.IpxeTemplateScopePublic, + RequiredParams: []string{}, ReservedParams: []string{}, RequiredArtifacts: []string{"iso"}, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, tmpl2.ID, site.ID) + + return &ipxeTemplateTestFixture{ip: ip, site: site, tmpl1: tmpl1, tmpl2: tmpl2} +} + +func createIpxeTemplateMockUser(org string) *cdbm.User { + return &cdbm.User{ + StarfleetID: cutil.GetPtr("test-user"), + OrgData: cdbm.OrgData{ + org: cdbm.Org{ + ID: 123, + Name: org, + DisplayName: org, + OrgType: "ENTERPRISE", + Roles: []string{"FORGE_PROVIDER_VIEWER"}, + }, + }, + } +} + +func createIpxeTemplateTenantMockUser(org string) *cdbm.User { + return &cdbm.User{ + StarfleetID: cutil.GetPtr("test-tenant-user"), + OrgData: cdbm.OrgData{ + org: cdbm.Org{ + ID: 456, + Name: org, + DisplayName: org, + OrgType: "ENTERPRISE", + Roles: []string{"FORGE_TENANT_ADMIN"}, + }, + }, + } +} + +func createIpxeTemplateMixedRoleMockUser(org string) *cdbm.User { + return &cdbm.User{ + StarfleetID: cutil.GetPtr("test-mixed-user"), + OrgData: cdbm.OrgData{ + org: cdbm.Org{ + ID: 789, + Name: org, + DisplayName: org, + OrgType: "ENTERPRISE", + Roles: []string{"FORGE_PROVIDER_VIEWER", "FORGE_TENANT_ADMIN"}, + }, + }, + } +} + +func TestGetAllIpxeTemplateHandler_Handle(t *testing.T) { + e := echo.New() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + + testIpxeTemplateHandlerSetupSchema(t, dbSession) + + ctx := context.Background() + cfg := &config.Config{} + handler := NewGetAllIpxeTemplateHandler(dbSession, nil, cfg) + + org := "test-org" + fix := testIpxeTemplateSetupTestData(t, dbSession, org) + + // Unmanaged site in a different org + unmanagedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "unmanaged-provider", Org: "other-org"} + _, err := dbSession.DB.NewInsert().Model(unmanagedIP).Exec(ctx) + assert.Nil(t, err) + + unmanagedSite := &cdbm.Site{ID: uuid.New(), Name: "unmanaged-site", Org: "other-org", InfrastructureProviderID: unmanagedIP.ID, Status: cdbm.SiteStatusRegistered} + _, err = dbSession.DB.NewInsert().Model(unmanagedSite).Exec(ctx) + assert.Nil(t, err) + + // Second provider-owned site with its own template, to exercise the + // "omitted siteId", multi-siteId, and per-site tenant-association paths. + site2 := &cdbm.Site{ID: uuid.New(), Name: "test-site-2", Org: org, InfrastructureProviderID: fix.ip.ID, Status: cdbm.SiteStatusRegistered} + _, err = dbSession.DB.NewInsert().Model(site2).Exec(ctx) + assert.Nil(t, err) + + tmpl3, err := cdbm.NewIpxeTemplateDAO(dbSession).Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "site2-public", Scope: cdbm.IpxeTemplateScopePublic, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, tmpl3.ID, site2.ID) + + // Tenant with a TenantSite association to fix.site only (not site2). + tenantOrg := "test-tenant-org" + tenantWithCapability := &cdbm.Tenant{ID: uuid.New(), Name: "test-tenant", Org: tenantOrg, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithCapability).Exec(ctx) + assert.Nil(t, err) + + tenantSite := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantWithCapability.ID, TenantOrg: tenantOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSite).Exec(ctx) + assert.Nil(t, err) + + // Non-privileged tenant WITH a TenantSite association — should still succeed. + tenantOrgNonPriv := "test-tenant-non-priv" + tenantNonPriv := &cdbm.Tenant{ID: uuid.New(), Name: "non-priv-tenant", Org: tenantOrgNonPriv, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantNonPriv).Exec(ctx) + assert.Nil(t, err) + + tenantSiteNonPriv := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantNonPriv.ID, TenantOrg: tenantOrgNonPriv, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteNonPriv).Exec(ctx) + assert.Nil(t, err) + + // Tenant with TenantSite to fix.site AND site2. + tenantOrgTwoSites := "test-tenant-two-sites" + tenantTwoSites := &cdbm.Tenant{ID: uuid.New(), Name: "two-sites-tenant", Org: tenantOrgTwoSites, Config: &cdbm.TenantConfig{}} + _, err = dbSession.DB.NewInsert().Model(tenantTwoSites).Exec(ctx) + assert.Nil(t, err) + + tenantSiteTwoA := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantTwoSites.ID, TenantOrg: tenantOrgTwoSites, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteTwoA).Exec(ctx) + assert.Nil(t, err) + + tenantSiteTwoB := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantTwoSites.ID, TenantOrg: tenantOrgTwoSites, SiteID: site2.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteTwoB).Exec(ctx) + assert.Nil(t, err) + + // Tenant without any TenantSite association. + tenantOrgNoCapability := "test-tenant-no-capability" + tenantWithoutCapability := &cdbm.Tenant{ID: uuid.New(), Name: "no-cap-tenant", Org: tenantOrgNoCapability, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutCapability).Exec(ctx) + assert.Nil(t, err) + + tenantOrgNoAccount := "test-tenant-no-site" + tenantWithoutAccount := &cdbm.Tenant{ID: uuid.New(), Name: "no-site-tenant", Org: tenantOrgNoAccount, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutAccount).Exec(ctx) + assert.Nil(t, err) + + // Mixed-role org. + mixedOrg := "mixed-role-org" + mixedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "mixed-provider", Org: mixedOrg} + _, err = dbSession.DB.NewInsert().Model(mixedIP).Exec(ctx) + assert.Nil(t, err) + + mixedTenant := &cdbm.Tenant{ID: uuid.New(), Name: "mixed-tenant", Org: mixedOrg, Config: &cdbm.TenantConfig{}} + _, err = dbSession.DB.NewInsert().Model(mixedTenant).Exec(ctx) + assert.Nil(t, err) + + mixedTenantSite := &cdbm.TenantSite{ID: uuid.New(), TenantID: mixedTenant.ID, TenantOrg: mixedOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(mixedTenantSite).Exec(ctx) + assert.Nil(t, err) + + _ = fix.tmpl1 + _ = fix.tmpl2 + _ = tmpl3 + _ = tenantSite + _ = tenantSiteNonPriv + _ = tenantWithoutCapability + _ = tenantWithoutAccount + + tests := []struct { + name string + siteIDs []string + setupContext func(c echo.Context) + expectedStatus int + checkResponseContent func(t *testing.T, body []byte) + }{ + { + name: "omitted siteId returns all templates available at provider's sites", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + // tmpl1, tmpl2 on fix.site + tmpl3 on site2. + assert.Len(t, response, 3) + }, + }, + { + name: "omitted siteId for tenant returns templates for tenant-accessible sites", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrg) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + // tenantOrg has a TenantSite on fix.site only, so only tmpl1 and + // tmpl2 are visible. + assert.Len(t, response, 2) + }, + }, + { + name: "multiple siteIds filters by the union", + siteIDs: []string{fix.site.ID.String(), site2.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 3) + }, + }, + { + name: "multiple siteIds with one unauthorized returns 403", + siteIDs: []string{fix.site.ID.String(), unmanagedSite.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "invalid siteId returns 400", + siteIDs: []string{"not-a-uuid"}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "successful GetAll with valid siteId", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + ids := map[string]bool{} + for _, tmpl := range response { + ids[tmpl.ID] = true + } + assert.True(t, ids[fix.tmpl1.ID.String()]) + assert.True(t, ids[fix.tmpl2.ID.String()]) + }, + }, + { + name: "cannot retrieve from unmanaged site", + siteIDs: []string{unmanagedSite.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "missing user context returns 500", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.SetParamNames("orgName") + c.SetParamValues(org) + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "tenant with TenantSite can retrieve templates for that site", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrg) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + }, + }, + { + name: "non-privileged tenant with TenantSite can retrieve templates", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNonPriv)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgNonPriv) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + }, + }, + { + name: "tenant without TenantSite is denied", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoCapability)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgNoCapability) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "tenant without TenantSite cannot access provider site", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoAccount)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgNoAccount) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "mixed-role user fails provider check but passes tenant authorization", + siteIDs: []string{fix.site.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMixedRoleMockUser(mixedOrg)) + c.SetParamNames("orgName") + c.SetParamValues(mixedOrg) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Len(t, response, 2) + }, + }, + { + name: "tenant associated with one site cannot access sibling site on same provider", + siteIDs: []string{site2.ID.String()}, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrg) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "tenant with TenantSite on multiple sites sees templates on each", + siteIDs: nil, + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgTwoSites)) + c.SetParamNames("orgName") + c.SetParamValues(tenantOrgTwoSites) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response []*model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + // tmpl1 and tmpl2 on fix.site + tmpl3 on site2 = 3 templates. + assert.Len(t, response, 3) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/v2/org/" + org + "/nico/ipxe-template" + params := []string{} + for _, sid := range tt.siteIDs { + params = append(params, "siteId="+sid) + } + if len(params) > 0 { + url += "?" + params[0] + for _, p := range params[1:] { + url += "&" + p + } + } + + req := httptest.NewRequest(http.MethodGet, url, nil) + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + tt.setupContext(c) + + err := handler.Handle(c) + assert.Nil(t, err) + assert.Equal(t, tt.expectedStatus, rec.Code) + if tt.expectedStatus != rec.Code { + t.Errorf("Response: %v", rec.Body.String()) + } + if tt.checkResponseContent != nil && rec.Code == http.StatusOK { + tt.checkResponseContent(t, rec.Body.Bytes()) + } + }) + } +} + +func TestGetIpxeTemplateHandler_Handle(t *testing.T) { + e := echo.New() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + + testIpxeTemplateHandlerSetupSchema(t, dbSession) + + ctx := context.Background() + cfg := &config.Config{} + handler := NewGetIpxeTemplateHandler(dbSession, nil, cfg) + + org := "test-org" + fix := testIpxeTemplateSetupTestData(t, dbSession, org) + + // Unmanaged site in a different org + unmanagedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "unmanaged-provider-get", Org: "other-org"} + _, err := dbSession.DB.NewInsert().Model(unmanagedIP).Exec(ctx) + assert.Nil(t, err) + + unmanagedSite := &cdbm.Site{ID: uuid.New(), Name: "unmanaged-site-get", Org: "other-org", InfrastructureProviderID: unmanagedIP.ID, Status: cdbm.SiteStatusRegistered} + _, err = dbSession.DB.NewInsert().Model(unmanagedSite).Exec(ctx) + assert.Nil(t, err) + + unmanagedTmpl, err := cdbm.NewIpxeTemplateDAO(dbSession).Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "unmanaged-tmpl", Scope: cdbm.IpxeTemplateScopePublic, + }) + assert.Nil(t, err) + associateTemplate(t, dbSession, unmanagedTmpl.ID, unmanagedSite.ID) + + // Tenant with a TenantSite association to fix.site. + tenantOrg := "test-tenant-org" + tenantWithCapability := &cdbm.Tenant{ID: uuid.New(), Name: "test-tenant", Org: tenantOrg, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithCapability).Exec(ctx) + assert.Nil(t, err) + + tenantSiteGet := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantWithCapability.ID, TenantOrg: tenantOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteGet).Exec(ctx) + assert.Nil(t, err) + + // Non-privileged tenant WITH a TenantSite — should succeed. + tenantOrgNonPrivGet := "test-tenant-non-priv-get" + tenantNonPrivGet := &cdbm.Tenant{ID: uuid.New(), Name: "non-priv-tenant-get", Org: tenantOrgNonPrivGet, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantNonPrivGet).Exec(ctx) + assert.Nil(t, err) + + tenantSiteNonPrivGet := &cdbm.TenantSite{ID: uuid.New(), TenantID: tenantNonPrivGet.ID, TenantOrg: tenantOrgNonPrivGet, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(tenantSiteNonPrivGet).Exec(ctx) + assert.Nil(t, err) + + // Tenant without any TenantSite (no site access). + tenantOrgNoCapability := "test-tenant-no-capability-get" + tenantWithoutCapability := &cdbm.Tenant{ID: uuid.New(), Name: "no-cap-tenant-get", Org: tenantOrgNoCapability, Config: &cdbm.TenantConfig{TargetedInstanceCreation: false}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutCapability).Exec(ctx) + assert.Nil(t, err) + + tenantOrgNoAccount := "test-tenant-no-site-get" + tenantWithoutAccount := &cdbm.Tenant{ID: uuid.New(), Name: "no-site-tenant-get", Org: tenantOrgNoAccount, Config: &cdbm.TenantConfig{TargetedInstanceCreation: true}} + _, err = dbSession.DB.NewInsert().Model(tenantWithoutAccount).Exec(ctx) + assert.Nil(t, err) + + // Mixed-role org: provider check fails (site belongs to fix.ip), tenant path + // succeeds via TenantSite association. + mixedOrg := "mixed-role-org-get" + mixedIP := &cdbm.InfrastructureProvider{ID: uuid.New(), Name: "mixed-provider-get", Org: mixedOrg} + _, err = dbSession.DB.NewInsert().Model(mixedIP).Exec(ctx) + assert.Nil(t, err) + + mixedTenant := &cdbm.Tenant{ID: uuid.New(), Name: "mixed-tenant-get", Org: mixedOrg, Config: &cdbm.TenantConfig{}} + _, err = dbSession.DB.NewInsert().Model(mixedTenant).Exec(ctx) + assert.Nil(t, err) + + mixedTenantSiteGet := &cdbm.TenantSite{ID: uuid.New(), TenantID: mixedTenant.ID, TenantOrg: mixedOrg, SiteID: fix.site.ID} + _, err = dbSession.DB.NewInsert().Model(mixedTenantSiteGet).Exec(ctx) + assert.Nil(t, err) + + _ = tenantSiteGet + _ = tenantSiteNonPrivGet + _ = tenantWithoutCapability + _ = tenantWithoutAccount + + tests := []struct { + name string + templateID string + setupContext func(c echo.Context) + expectedStatus int + checkResponseContent func(t *testing.T, body []byte) + }{ + { + name: "successful retrieval", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + assert.Equal(t, "kernel-initrd", response.Name) + }, + }, + { + name: "template not found", + templateID: uuid.New().String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, uuid.New().String()) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid uuid returns bad request", + templateID: "not-a-uuid", + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, "not-a-uuid") + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "cannot retrieve from unmanaged site", + templateID: unmanagedTmpl.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMockUser(org)) + c.SetParamNames("orgName", "id") + c.SetParamValues(org, unmanagedTmpl.ID.String()) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "missing user context returns 500", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.SetParamNames("orgName", "id") + c.SetParamValues(org, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "tenant with TenantSite can retrieve template", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrg)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrg, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + }, + }, + { + name: "non-privileged tenant with TenantSite can retrieve template", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNonPrivGet)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrgNonPrivGet, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + }, + }, + { + name: "tenant without TenantSite is denied", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoCapability)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrgNoCapability, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "tenant without TenantSite on requested site is denied", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateTenantMockUser(tenantOrgNoAccount)) + c.SetParamNames("orgName", "id") + c.SetParamValues(tenantOrgNoAccount, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "mixed-role user fails provider check but passes tenant authorization", + templateID: fix.tmpl1.ID.String(), + setupContext: func(c echo.Context) { + c.Set("user", createIpxeTemplateMixedRoleMockUser(mixedOrg)) + c.SetParamNames("orgName", "id") + c.SetParamValues(mixedOrg, fix.tmpl1.ID.String()) + }, + expectedStatus: http.StatusOK, + checkResponseContent: func(t *testing.T, body []byte) { + var response model.APIIpxeTemplate + assert.Nil(t, json.Unmarshal(body, &response)) + assert.Equal(t, fix.tmpl1.ID.String(), response.ID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/v2/org/" + org + "/nico/ipxe-template/" + tt.templateID + req := httptest.NewRequest(http.MethodGet, url, nil) + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + tt.setupContext(c) + + err := handler.Handle(c) + assert.Nil(t, err) + assert.Equal(t, tt.expectedStatus, rec.Code) + if tt.expectedStatus != rec.Code { + t.Errorf("Response: %v", rec.Body.String()) + } + if tt.checkResponseContent != nil && rec.Code == http.StatusOK { + tt.checkResponseContent(t, rec.Body.Bytes()) + } + }) + } +} diff --git a/rest-api/api/pkg/api/handler/operatingsystem.go b/rest-api/api/pkg/api/handler/operatingsystem.go index d166924a09..ffc81ca772 100644 --- a/rest-api/api/pkg/api/handler/operatingsystem.go +++ b/rest-api/api/pkg/api/handler/operatingsystem.go @@ -5,11 +5,11 @@ package handler import ( "context" + "database/sql" "encoding/json" "errors" "fmt" "net/http" - "slices" "go.opentelemetry.io/otel/attribute" temporalClient "go.temporal.io/sdk/client" @@ -24,14 +24,17 @@ import ( "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/pagination" sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site" - auth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization" cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" cdbp "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" swe "github.com/NVIDIA/infra-controller/rest-api/site-workflow/pkg/error" "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/queue" + osWorkflow "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/workflow/operatingsystem" + + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" ) // ~~~~~ Create Handler ~~~~~ // @@ -76,49 +79,35 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to create OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, csh.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } - // Validate request - // Bind request data to API model + // Bind request data to API model before OS-type check so we can inspect the OS type. apiRequest := model.APIOperatingSystemCreateRequest{} - err = c.Bind(&apiRequest) + err := c.Bind(&apiRequest) if err != nil { logger.Warn().Err(err).Msg("error binding request data into API model") return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request data, potentially invalid structure", nil) } - // Validate the tenant for which this OperatingSystem is being created - tenant, err := common.GetTenantForOrg(ctx, nil, csh.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) - } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) + // Infer type of OS from provided parameters: + osType := apiRequest.GetOperatingSystemType() + + // Image-based OS creation is not supported via this handler; Image OS + // definitions originate from nico-core inventory synchronization. + if osType == cdbm.OperatingSystemTypeImage { + logger.Warn().Msg("attempted to create Image based Operating System via API") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Creation of Image based Operating Systems is no longer supported. Check your parameters and use ipxeScript or ipxeTemplateId.", nil) } - // Default TenantID to org's Tenant when nil; validate when set - if apiRequest.TenantID == nil { - apiRequest.TenantID = cutil.GetPtr(tenant.ID.String()) - } else if *apiRequest.TenantID != tenant.ID.String() { - logger.Warn().Str("tenantId", *apiRequest.TenantID).Msg("TenantID in request does not match org's Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID specified in request does not match org's Tenant", nil) + // Provider Admin is limited to iPXE Template-based OSes. When both roles + // allow the action, Provider Admin takes priority (provider-owned OS). + allowedByProvider := ip != nil && osType == cdbm.OperatingSystemTypeTemplatedIPXE + allowedByTenant := tenant != nil + if !allowedByProvider && !allowedByTenant { + logger.Warn().Msg("provider admin attempted to create non-template OS without tenant admin role") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only create iPXE Template-based Operating Systems", nil) } // Validate request attributes @@ -135,294 +124,303 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating user data in Operating System creation request", verr) } - // check for name uniqueness for the tenant, ie, tenant cannot have another os with same name - // TODO consider doing this with an advisory lock for correctness + // If the caller provided an explicit tenantId in the body, validate it matches the org. + // TODO: tenantId as parameter is deprecated and will need to be removed by 2026-10-01. + if tenant != nil && apiRequest.TenantID != nil { + apiTenant, terr := common.GetTenantFromIDString(ctx, nil, *apiRequest.TenantID, csh.dbSession) + if terr != nil { + logger.Warn().Err(terr).Msg("error retrieving tenant from request") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request is not valid", nil) + } + if apiTenant.ID != tenant.ID { + logger.Warn().Msg("tenant id in request does not match tenant in org") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "TenantID in request does not match tenant in org", nil) + } + } + + // Check for name uniqueness within the owner's scope. osDAO := cdbm.NewOperatingSystemDAO(csh.dbSession) - oss, tot, err := osDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - Names: []string{apiRequest.Name}, - }, - cdbp.PageInput{}, - nil, - ) + uniquenessFilter := cdbm.OperatingSystemFilterInput{Names: []string{apiRequest.Name}} + if allowedByProvider { + uniquenessFilter.InfrastructureProviderID = &ip.ID + } else { + uniquenessFilter.TenantIDs = []uuid.UUID{tenant.ID} + } + oss, tot, err := osDAO.GetAll(ctx, nil, uniquenessFilter, cdbp.PageInput{}, nil) if err != nil { - logger.Error().Err(err).Msg("db error checking for name uniqueness of tenant os") + logger.Error().Err(err).Msg("db error checking for name uniqueness of os") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create OperatingSystem due to DB error", nil) } if tot > 0 { - logger.Warn().Str("tenantId", tenant.ID.String()).Str("name", apiRequest.Name).Msg("Operating System with same name already exists for tenant") - return cutil.NewAPIErrorResponse(c, http.StatusConflict, "Another Operating System with specified name already exists for Tenant", validation.Errors{ + logger.Warn().Str("name", apiRequest.Name).Msg("Operating System with same name already exists") + return cutil.NewAPIErrorResponse(c, http.StatusConflict, fmt.Sprintf("Operating System: %s with specified name already exists", oss[0].ID.String()), validation.Errors{ "id": errors.New(oss[0].ID.String()), }) } - // check OS type from request - osType := cdbm.OperatingSystemTypeImage - if apiRequest.IpxeScript != nil { - osType = cdbm.OperatingSystemTypeIPXE - } - // Set the phoneHomeEnabled if provided in request phoneHomeEnabled := false if apiRequest.PhoneHomeEnabled != nil { phoneHomeEnabled = *apiRequest.PhoneHomeEnabled } - // Verify or validate site - tsDAO := cdbm.NewTenantSiteDAO(csh.dbSession) - rdbst := []cdbm.Site{} - sttsmap := map[uuid.UUID]*cdbm.TenantSite{} - dbossd := []cdbm.StatusDetail{} - - // Get all TenantSite records for the Tenant - tss, _, err := tsDAO.GetAll( - ctx, - nil, - cdbm.TenantSiteFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - }, - cdbp.PageInput{ - Limit: cutil.GetPtr(cdbp.TotalLimit), - }, - nil, - ) + // Start a db tx + tx, err := cdb.BeginTx(ctx, csh.dbSession, &sql.TxOptions{}) if err != nil { - logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) + logger.Error().Err(err).Msg("unable to start transaction") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Operating System", nil) } - for _, ts := range tss { - cts := ts - sttsmap[ts.SiteID] = &cts + + // This variable is used in cleanup actions to indicate if this transaction committed + txCommitted := false + defer common.RollbackTx(ctx, tx, &txCommitted) + + // Determine the effective scope before site-association logic: + // - Raw iPXE: always Global. validateRawIpxeOS accepts only nil + // or "Global"; the handler then forces it to Global so + // downstream logic can treat it uniformly. + // - Templated iPXE: scope is provided by the caller (Global or Limited). + osScope := apiRequest.Scope + if osType == cdbm.OperatingSystemTypeIPXE { + osScope = cutil.GetPtr(cdbm.OperatingSystemScopeGlobal) } - // Validate the site for which this image based Operating System is being created - for _, stID := range apiRequest.SiteIDs { - site, serr := common.GetSiteFromIDString(ctx, nil, stID, csh.dbSession) - if serr != nil { - if serr == common.ErrInvalidID { + // Resolve target sites for the Operating System. + // - Global scope: auto-discover all registered sites for the owner (provider or tenant). + // - Limited scope: use explicitly requested siteIds, validated for existence and ownership. + // Note: scope "Local" is rejected at validation — Local OS are only created in nico-core. + dbossd := []cdbm.StatusDetail{} + sttsmap := map[uuid.UUID]*cdbm.TenantSite{} + + isGlobal := osScope != nil && *osScope == cdbm.OperatingSystemScopeGlobal + isLimited := osScope != nil && *osScope == cdbm.OperatingSystemScopeLimited + + stDAO := cdbm.NewSiteDAO(csh.dbSession) + var targetSites []cdbm.Site + siteFilter := cdbm.SiteFilterInput{} + runSiteQuery := false + + if isLimited { + // Limited-scope iPXE: resolve the explicitly requested site IDs. + if ip == nil { + ip, err = common.GetInfrastructureProviderForOrg(ctx, nil, csh.dbSession, org) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Infrastructure Provider for org") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Infrastructure Provider for org", nil) + } + } + + requestedSiteIDs := make([]uuid.UUID, 0, len(apiRequest.SiteIDs)) + for _, stID := range apiRequest.SiteIDs { + parsed, perr := uuid.Parse(stID) + if perr != nil { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, invalid Site ID: %s", stID), nil) } - if serr == cdb.ErrDoesNotExist { - return cutil.NewAPIErrorResponse(c, http.StatusNotFound, fmt.Sprintf("Failed to create Operating System, could not find Site with ID: %s ", stID), nil) + requestedSiteIDs = append(requestedSiteIDs, parsed) + } + siteFilter.SiteIDs = requestedSiteIDs + runSiteQuery = len(requestedSiteIDs) > 0 + } else if isGlobal { + // Global scope: auto-discover all registered sites for the owner. + siteFilter.Statuses = []string{cdbm.SiteStatusRegistered} + if allowedByProvider { + siteFilter.InfrastructureProviderIDs = []uuid.UUID{ip.ID} + runSiteQuery = true + } else { + // Tenant Global (raw iPXE): restrict to sites accessible to the tenant. + tenantSiteIDs, tserr := getTenantSiteIDs(ctx, csh.dbSession, tenant.ID) + if tserr != nil { + logger.Error().Err(tserr).Msg("error retrieving tenant site IDs for global-scope raw iPXE OS") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant sites, DB error", nil) + } + if len(tenantSiteIDs) > 0 { + siteFilter.SiteIDs = tenantSiteIDs + runSiteQuery = true } - logger.Warn().Err(serr).Str("Site ID", stID).Msg("error retrieving Site from DB by ID") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, could not retrieve Site with ID: %s, DB error", stID), nil) } + } - if site.Status != cdbm.SiteStatusRegistered { - logger.Warn().Msg(fmt.Sprintf("Unable to associate Operating System to Site: %s. Site is not in Registered state", site.ID.String())) - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, Site: %s specified in request is not in Registered state", site.ID.String()), nil) + if runSiteQuery { + sites, _, sterr := stDAO.GetAll( + ctx, nil, + siteFilter, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if sterr != nil { + logger.Error().Err(sterr).Msg("error retrieving sites for Operating System") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve sites, DB error", nil) } + targetSites = sites + } - // Validate the TenantSite exists for current tenant and this site - _, ok := sttsmap[site.ID] - if !ok { - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Unable to associate Operating System with Site: %s, Tenant does not have access to Site", stID), nil) + // For Limited scope, ensure every requested site was found. + if isLimited && len(targetSites) != len(apiRequest.SiteIDs) { + found := make(map[uuid.UUID]struct{}, len(targetSites)) + for i := range targetSites { + found[targetSites[i].ID] = struct{}{} } - - // Validate the Site has the ImageBasedOperatingSystem capability enabled for Image based Operating Systems - if osType == cdbm.OperatingSystemTypeImage && (site.Config == nil || !site.Config.ImageBasedOperatingSystem) { - logger.Warn().Str("siteId", stID).Msg("Image based Operating System is not supported for Site, ImageBasedOperatingSystem capability is not enabled") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Creation of Image based Operating Systems is not supported. Site must have ImageBasedOperatingSystem capability enabled.", nil) + for _, stID := range apiRequest.SiteIDs { + parsed, _ := uuid.Parse(stID) + if _, ok := found[parsed]; !ok { + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, fmt.Sprintf("Failed to create Operating System, could not find Site with ID: %s ", stID), nil) + } } - - rdbst = append(rdbst, *site) } - // Create status based on OS type - osStatus := cdbm.OperatingSystemStatusReady - osStatusMessage := "Operating System is ready for use" - if osType == cdbm.OperatingSystemTypeImage { - osStatus = cdbm.OperatingSystemStatusSyncing - osStatusMessage = "received Operating System creation request, syncing" - } - - // Values needed after the transaction closure - var os *cdbm.OperatingSystem - var dbossa []cdbm.OperatingSystemSiteAssociation - // timeoutResp captures any post-rollback work (terminating timed-out - // Temporal workflows) that must run after the transaction has been rolled - // back. It is invoked after the closure if non-nil. - var timeoutResp func() 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, - IsCloudInit: apiRequest.IsCloudInit, - AllowOverride: apiRequest.AllowOverride, - EnableBlockStorage: apiRequest.EnableBlockStorage, - PhoneHomeEnabled: phoneHomeEnabled, - Status: osStatus, - CreatedBy: dbUser.ID, - } - createdOs, derr := osDAO.Create(ctx, tx, osInput) - if derr != nil { - logger.Error().Err(derr).Msg("unable to create Operating System record in DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed creating Operating System record", nil) + // Validate all target sites: must be in Registered state and, for Limited scope, + // must belong to the caller's infrastructure provider (if set). + for i := range targetSites { + st := &targetSites[i] + if st.Status != cdbm.SiteStatusRegistered { + logger.Warn().Str("siteID", st.ID.String()).Msg("Unable to associate Operating System to Site: Site is not in Registered state") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to create Operating System, Site: %s is not in Registered state", st.ID.String()), nil) } - os = createdOs - - // Create the status detail record for Operating System - sdDAO := cdbm.NewStatusDetailDAO(csh.dbSession) - ossd, derr := sdDAO.CreateFromParams(ctx, tx, os.ID.String(), *cutil.GetPtr(osStatus), - cutil.GetPtr(osStatusMessage)) - if 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) + if isLimited && ip != nil && st.InfrastructureProviderID != ip.ID { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Unable to associate Operating System with Site: %s, Site does not belong to provider", st.ID.String()), nil) } + } - if ossd == nil { - logger.Error().Msg("Status Detail DB entry not returned from CreateFromParams") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to get new Status Detail for Operating System", nil) - } - dbossd = append(dbossd, *ossd) - - // Create Operating System Site Associations - ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(csh.dbSession) - for _, st := range rdbst { - // Create Operating System Site Association - ossa, derr := ossaDAO.Create( - ctx, - tx, - cdbm.OperatingSystemSiteAssociationCreateInput{ - OperatingSystemID: os.ID, - SiteID: st.ID, - Status: cdbm.OperatingSystemSiteAssociationStatusSyncing, - CreatedBy: dbUser.ID, - }, - ) - if derr != nil { - logger.Error().Err(derr).Msg("unable to create the Operating System association record in DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to associate Operating System with one or more Sites, DB error", nil) - } + // Create status: starts as Syncing since the definition is pushed to sites + // asynchronously via the SynchronizeOperatingSystem workflow. + osStatus := cdbm.OperatingSystemStatusSyncing + osStatusMessage := "received Operating System creation request, syncing" - // Create Status details - _, derr = sdDAO.CreateFromParams(ctx, tx, ossa.ID.String(), *cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing), - cutil.GetPtr("received Operating System Association create request, syncing")) - if 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) - } + // Assign ownership: provider-owned OSes carry InfrastructureProviderID (tenant_id=nil); + // tenant-owned OSes carry TenantID (infrastructure_provider_id=nil). + // This aligns with the sync model where OSes from nico-core are provider-owned. + var ownerTenantID *uuid.UUID + var ownerProviderID *uuid.UUID + if allowedByProvider { + ownerProviderID = &ip.ID + } else { + ownerTenantID = &tenant.ID + } + + // Create the db record for Operating System + osInput := cdbm.OperatingSystemCreateInput{ + Name: apiRequest.Name, + Description: apiRequest.Description, + Org: org, + TenantID: ownerTenantID, + InfrastructureProviderID: ownerProviderID, + OsType: osType, + ImageURL: apiRequest.ImageURL, + ImageSHA: apiRequest.ImageSHA, + ImageAuthType: apiRequest.ImageAuthType, + ImageAuthToken: apiRequest.ImageAuthToken, + ImageDisk: apiRequest.ImageDisk, + RootFsId: apiRequest.RootFsID, + RootFsLabel: apiRequest.RootFsLabel, + IpxeScript: apiRequest.IpxeScript, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + IpxeOsScope: osScope, + UserData: apiRequest.UserData, + IsCloudInit: apiRequest.IsCloudInit, + AllowOverride: apiRequest.AllowOverride, + EnableBlockStorage: apiRequest.EnableBlockStorage, + PhoneHomeEnabled: phoneHomeEnabled, + Status: osStatus, + CreatedBy: dbUser.ID, + } + os, err := osDAO.Create(ctx, tx, osInput) + if err != nil { + logger.Error().Err(err).Msg("unable to create Operating System record in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed creating Operating System record", nil) + } - // Update Operating System Site Association version - _, derr = ossaDAO.GenerateAndUpdateVersion(ctx, tx, ossa.ID) - if derr != nil { - logger.Error().Err(derr).Msg("error updating version for created Operating System Association") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to set version for created Operating System Association, DB error", nil) - } - } + // Create the status detail record for Operating System + sdDAO := cdbm.NewStatusDetailDAO(csh.dbSession) + ossd, serr := sdDAO.CreateFromParams(ctx, tx, os.ID.String(), *cutil.GetPtr(osStatus), + cutil.GetPtr(osStatusMessage)) + if serr != nil { + logger.Error().Err(serr).Msg("error creating Status Detail DB entry") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Status Detail for Operating System", nil) + } + + if ossd == nil { + logger.Error().Msg("Status Detail DB entry not returned from CreateFromParams") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to get new Status Detail for Operating System", nil) + } + dbossd = append(dbossd, *ossd) - // Retrieve Operating System Associations details - retossa, _, derr := ossaDAO.GetAll( + // Create Operating System Site Associations + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(csh.dbSession) + for _, st := range targetSites { + // Create Operating System Site Association + ossa, serr := ossaDAO.Create( ctx, tx, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{os.ID}, - }, - cdbp.PageInput{ - Limit: cutil.GetPtr(cdbp.TotalLimit), + cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: os.ID, + SiteID: st.ID, + Status: cdbm.OperatingSystemSiteAssociationStatusSyncing, + CreatedBy: dbUser.ID, }, - []string{cdbm.SiteRelationName, cdbm.OperatingSystemRelationName}, ) - if derr != nil { - logger.Error().Err(derr).Msg("error retrieving Operating System Site associations from DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + if serr != nil { + logger.Error().Err(serr).Msg("unable to create the Operating System association record in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to associate Operating System with one or more Sites, DB error", nil) } - dbossa = retossa - - // Trigger workflows to sync Image based Operating System with various Sites - for _, ossa := range dbossa { - // 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. - iterErr := func() *cutil.APIError { - // Get the temporal client for the site we are working with. - stc, derr := csh.scp.GetClientByID(ossa.SiteID) - if derr != nil { - logger.Error().Err(derr).Msg("failed to retrieve Temporal client for Site") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve client for Site", nil) - } - createOsRequest := apiRequest.ToProto(os, tenant.Org) - - workflowOptions := temporalClient.StartWorkflowOptions{ - ID: "image-os-create-" + ossa.SiteID.String() + "-" + os.ID.String() + "-" + *ossa.Version, - WorkflowExecutionTimeout: cutil.WorkflowExecutionTimeout, - TaskQueue: queue.SiteTaskQueue, - } - - logger.Info().Str("Site ID", ossa.SiteID.String()).Msg("triggering Image based Operating System create workflow ") + // Create Status details + _, serr = sdDAO.CreateFromParams(ctx, tx, ossa.ID.String(), *cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing), + cutil.GetPtr("received Operating System Association create request, syncing")) + if serr != nil { + logger.Error().Err(serr).Msg("error creating Status Detail DB entry") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Status Detail for Operating System Association", nil) + } - // Add context deadlines - wfCtx, cancel := context.WithTimeout(ctx, cutil.WorkflowContextTimeout) - defer cancel() + // Update Operating System Site Association version + _, err := ossaDAO.GenerateAndUpdateVersion(ctx, tx, ossa.ID) + if err != nil { + logger.Error().Err(err).Msg("error updating version for created Operating System Association") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to set version for created Operating System Association, DB error", nil) + } + } - // Trigger Site workflow - we, wferr := stc.ExecuteWorkflow(wfCtx, workflowOptions, "CreateOsImage", createOsRequest) - if wferr != nil { - logger.Error().Err(wferr).Msg("failed to synchronously start Temporal workflow to create Operating System") - return cutil.NewAPIError(http.StatusInternalServerError, fmt.Sprintf("Failed start sync workflow to create Operating System on Site: %s", wferr), nil) - } + // Retrieve Operating System Associations details + dbossa, _, err := ossaDAO.GetAll( + ctx, + tx, + cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{os.ID}, + }, + cdbp.PageInput{ + Limit: cutil.GetPtr(cdbp.TotalLimit), + }, + []string{cdbm.SiteRelationName, cdbm.OperatingSystemRelationName}, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } - wid := we.GetID() - logger.Info().Str("Workflow ID", wid).Msg("executed synchronous create Operating System workflow") - - // Block until the workflow has completed and returned success/error. - wferr = we.Get(wfCtx, nil) - if wferr != nil { - var timeoutErr *tp.TimeoutError - if errors.As(wferr, &timeoutErr) || wferr == context.DeadlineExceeded || wfCtx.Err() != nil { - logger.Error().Err(wferr).Msg("failed to create Operating System, timeout occurred executing workflow on Site.") - timeoutResp = func() error { - return common.TerminateWorkflowOnTimeOut(c, logger, stc, wid, wferr, "OperatingSystem", "Create") - } - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to create Operating System, timeout occurred executing workflow on Site", nil) - } + targetSiteIDs := make([]uuid.UUID, len(targetSites)) + for i, site := range targetSites { + targetSiteIDs[i] = site.ID + } - code, uwerr := common.UnwrapWorkflowError(wferr) - logger.Error().Err(uwerr).Msg("failed to synchronously execute Temporal workflow to create Operating System") - return cutil.NewAPIError(code, fmt.Sprintf("Failed to execute sync workflow to create Operating System on Site: %s", uwerr), nil) - } - logger.Info().Str("Workflow ID", wid).Str("Site ID", ossa.SiteID.String()).Msg("completed synchronous create Operating System workflow") - return nil - }() - if iterErr != nil { - return iterErr - } + // Trigger async workflow before committing so a failure to enqueue rolls back the transaction. + // Note: first run WILL fail since data is not committed so we rely on retry. We choose that initial inocuous failure vs failing to queue silently. + if cdbm.IsIPXEType(osType) && len(dbossa) > 0 { + wid, werr := osWorkflow.ExecuteCreateOrUpdateOperatingSystemByIDWorkflow(ctx, csh.tc, targetSiteIDs, os.ID) + if werr != nil { + logger.Error().Err(werr).Msg("failed to trigger SynchronizeOperatingSystem workflow for create") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to trigger Operating System synchronization workflow", nil) } + logger.Info().Str("Workflow ID", *wid).Interface("Site IDs", targetSiteIDs).Msg("triggered async CreateOrUpdateOperatingSystemByID workflow for create") + } - return nil - }) - // The wrapping `if err != nil` ensures real tx-helper errors (commit / - // rollback failures that wrap into something other than the cutil.APIError - // marker we returned for the timeout case) are surfaced via HandleTxError, - // while the timeout-case APIError falls through to the timeoutResp call. + // Commit transaction. + err = tx.Commit() if err != nil { - var apiErr *cutil.APIError - if !errors.As(err, &apiErr) || timeoutResp == nil { - return common.HandleTxError(c, logger, err, "Failed to create Operating System due to DB transaction error") - } - } - if timeoutResp != nil { - return timeoutResp() + logger.Error().Err(err).Msg("error committing Operating System transaction to DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Operating System", nil) } + txCommitted = true // create response apiOperatingSystem := model.NewAPIOperatingSystem(os, dbossd, dbossa, sttsmap) @@ -477,27 +475,14 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to retrieve OperatingSystems - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gash.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Validate pagination request pageRequest := pagination.PageRequest{} - err = c.Bind(&pageRequest) + err := c.Bind(&pageRequest) if 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) @@ -510,20 +495,32 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to validate pagination request data", err) } - // Validate the tenant associated with the org - tenant, err := common.GetTenantForOrg(ctx, nil, gash.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) + // Visibility rules: + // Provider admin: sees only provider-created entries (no tenant entries). + // Tenant admin: sees own entries + provider entries at tenant-accessible sites. + // Dual-role: visibility is the union of both (own tenant + own provider). + filter := cdbm.OperatingSystemFilterInput{} + + switch { + case ip != nil && tenant == nil: + // Provider admin only: sees only provider-created entries. + filter.InfrastructureProviderID = &ip.ID + case tenant != nil && ip == nil: + // Tenant admin only: own entries + provider entries at tenant-accessible sites. + filter.TenantIDs = []uuid.UUID{tenant.ID} + if providerIP, iperr := common.GetInfrastructureProviderForOrg(ctx, nil, gash.dbSession, org); iperr == nil { + filter.InfrastructureProviderID = &providerIP.ID + tenantSiteIDs, tsErr := getTenantSiteIDs(ctx, gash.dbSession, tenant.ID) + if tsErr != nil { + logger.Error().Err(tsErr).Msg("error retrieving tenant site IDs for visibility filter") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to determine site access for tenant", nil) + } + filter.ProviderOSVisibleAtSiteIDs = &tenantSiteIDs } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) - } - - filter := cdbm.OperatingSystemFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - Orgs: []string{org}, + case tenant != nil && ip != nil: + // Dual-role: own tenant + own provider entries, no site restriction. + filter.TenantIDs = []uuid.UUID{tenant.ID} + filter.InfrastructureProviderID = &ip.ID } // Get and validate includeRelation params @@ -546,14 +543,22 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to retrieve Site specified in query", nil) } - // Determine if tenant has access to requested site - _, err = tsDAO.GetByTenantIDAndSiteID(ctx, nil, tenant.ID, site.ID, nil) - if err != nil { - if err == cdb.ErrDoesNotExist { - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant is not associated with Site specified in query", nil) + // Determine if caller has access to the requested site. + // Tenant path: TenantSite association exists. + // Provider path: site belongs to the caller's infrastructure provider. + tenantHasAccess := false + if tenant != nil { + _, tsErr := tsDAO.GetByTenantIDAndSiteID(ctx, nil, tenant.ID, site.ID, nil) + if tsErr == nil { + tenantHasAccess = true + } else if tsErr != cdb.ErrDoesNotExist { + logger.Warn().Err(tsErr).Msg("error retrieving Tenant Site association from DB") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to determine if Tenant has access to Site specified in query, DB error", nil) } - logger.Warn().Err(err).Msg("error retrieving Tenant Site association from DB") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to determine if Tenant has access to Site specified in query, DB error", nil) + } + providerHasAccess := ip != nil && site.InfrastructureProviderID == ip.ID + if !tenantHasAccess && !providerHasAccess { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Caller is not associated with Site specified in query", nil) } filter.SiteIDs = append(filter.SiteIDs, site.ID) } @@ -573,10 +578,10 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { } // Get query text for full text search from query param - searchQuery := common.GetSearchQuery(c) - if searchQuery != nil { - filter.SearchQuery = searchQuery - gash.tracerSpan.SetAttribute(handlerSpan, attribute.String("query", *searchQuery), logger) + searchQueryStr := c.QueryParam("query") + if searchQueryStr != "" { + filter.SearchQuery = &searchQueryStr + gash.tracerSpan.SetAttribute(handlerSpan, attribute.String("query", searchQueryStr), logger) } // Get status from query param @@ -589,7 +594,7 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { statusError := validation.Errors{ "status": errors.New(status), } - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Status value in query", statusError) + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid Status value in query: %s", status), statusError) } filter.Statuses = append(filter.Statuses, status) } @@ -664,40 +669,39 @@ func (gash GetAllOperatingSystemHandler) Handle(c echo.Context) error { dbossaMap[dbossa.OperatingSystemID] = append(dbossaMap[dbossa.OperatingSystemID], curVal) } - // Get all TenantSite records for the Tenant + // Get all TenantSite records for the Tenant (only relevant when the caller + // is acting as a Tenant; provider-only admins have no tenant-site context). sttsmap := map[uuid.UUID]*cdbm.TenantSite{} - tsDAO = cdbm.NewTenantSiteDAO(gash.dbSession) - tss, _, err := tsDAO.GetAll( - ctx, - nil, - cdbm.TenantSiteFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - SiteIDs: siteIDs, - }, - cdbp.PageInput{ - Limit: cutil.GetPtr(cdbp.TotalLimit), - }, - nil, - ) - if err != nil { - logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) - } + if tenant != nil { + tsDAO = cdbm.NewTenantSiteDAO(gash.dbSession) + tss, _, err := tsDAO.GetAll( + ctx, + nil, + cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + SiteIDs: siteIDs, + }, + cdbp.PageInput{ + Limit: cutil.GetPtr(cdbp.TotalLimit), + }, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) + } - for _, ts := range tss { - curVal := ts - sttsmap[ts.SiteID] = &curVal + for _, ts := range tss { + curVal := ts + sttsmap[ts.SiteID] = &curVal + } } // Create response 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) @@ -760,22 +764,9 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to retrieve OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, gsh.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Get and validate includeRelation params @@ -799,17 +790,6 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { osDAO := cdbm.NewOperatingSystemDAO(gsh.dbSession) - // Validate the tenant for which this OperatingSystem is being retrieved - tenant, err := common.GetTenantForOrg(ctx, nil, gsh.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) - } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) - } - // Check that operating system exists os, err := osDAO.GetByID(ctx, nil, sID, qIncludeRelations) if err != nil { @@ -820,10 +800,68 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Could not retrieve OperatingSystem to update", nil) } - // verify tenant matches - if os.TenantID == nil || tenant.ID != *os.TenantID { - logger.Warn().Msg("tenant in org does not match tenant in operating system") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant for OperatingSystem in request does not match tenant in org", nil) + // Visibility check with role-based rules: + // Provider admin: can only see provider-owned entries. + // Tenant admin: can see own entries + provider entries at accessible sites. + // Dual-role: can see both tenant and provider entries. + ownedByTenant := tenant != nil && os.TenantID != nil && *os.TenantID == tenant.ID + ownedByProvider := ip != nil && os.InfrastructureProviderID != nil && *os.InfrastructureProviderID == ip.ID + + // A tenant-only caller may also view provider-owned OSes belonging to the org's + // provider (subject to site-scoped visibility checked below). Lazy-fetch the + // org's provider to evaluate that case. + if !ownedByProvider && ip == nil && os.InfrastructureProviderID != nil { + if providerIP, iperr := common.GetInfrastructureProviderForOrg(ctx, nil, gsh.dbSession, org); iperr == nil { + ownedByProvider = *os.InfrastructureProviderID == providerIP.ID + } + } + + if !ownedByTenant && !ownedByProvider { + logger.Warn().Msg("operating system does not belong to the tenant or provider in org") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to the tenant or infrastructure provider in org", nil) + } + + // If caller has dual role (Tenant+Provider) we already know we can go forward. + // Otherwise we need additional checks: + if !(tenant != nil && ip != nil) { + if ip != nil && !ownedByProvider { + logger.Warn().Msg("provider admin cannot view tenant-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to the infrastructure provider in org", nil) + } + if tenant != nil && !ownedByTenant && ownedByProvider { + // Tenant admin seeing a provider-owned entry: verify site-scoped visibility. + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(gsh.dbSession) + ossas, _, ossaErr := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if ossaErr != nil { + logger.Error().Err(ossaErr).Msg("error retrieving OS site associations for visibility check") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify site access for Operating System", nil) + } + + tenantSiteIDs, tsErr := getTenantSiteIDs(ctx, gsh.dbSession, tenant.ID) + if tsErr != nil { + logger.Error().Err(tsErr).Msg("error retrieving tenant site IDs for visibility check") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to determine site access for tenant", nil) + } + tsSet := make(map[uuid.UUID]struct{}, len(tenantSiteIDs)) + for _, sid := range tenantSiteIDs { + tsSet[sid] = struct{}{} + } + visible := false + for _, ossa := range ossas { + if _, ok := tsSet[ossa.SiteID]; ok { + visible = true + break + } + } + if !visible { + logger.Warn().Msg("provider-owned OS has no site associations at sites accessible to the tenant") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System is not associated with any site accessible to the caller", nil) + } + } } // get status details for the response @@ -834,28 +872,28 @@ func (gsh GetOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Status Details for OperatingSystem", nil) } - dbossas := []cdbm.OperatingSystemSiteAssociation{} - sttsmap := map[uuid.UUID]*cdbm.TenantSite{} - if os.Type == cdbm.OperatingSystemTypeImage { - // Get all OperatingSystemSiteAssociations - ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(gsh.dbSession) - dbossas, _, err = ossaDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{os.ID}, - }, - cdbp.PageInput{ - Limit: cutil.GetPtr(cdbp.TotalLimit), - }, - []string{cdbm.SiteRelationName}, - ) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) - } + // Get all OperatingSystemSiteAssociations (both iPXE and Image types may have them). + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(gsh.dbSession) + dbossas, _, err := ossaDAO.GetAll( + ctx, + nil, + cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{os.ID}, + }, + cdbp.PageInput{ + Limit: cutil.GetPtr(cdbp.TotalLimit), + }, + []string{cdbm.SiteRelationName}, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } - // Get all TenantSite records for the Tenant + // Get all TenantSite records for the Tenant (only relevant when the caller + // is acting as a Tenant; provider-only admins have no tenant-site context). + sttsmap := map[uuid.UUID]*cdbm.TenantSite{} + if tenant != nil { tsDAO := cdbm.NewTenantSiteDAO(gsh.dbSession) tss, _, err := tsDAO.GetAll( ctx, @@ -928,22 +966,9 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to update OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, ush.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Get os ID from URL param @@ -978,6 +1003,13 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Could not retrieve OperatingSystem to update", nil) } + // Image-based OS updates are not supported via this handler; Image OS + // definitions are managed through nico-core inventory synchronization. + if os.Type == cdbm.OperatingSystemTypeImage { + logger.Warn().Msg("attempted to update Image based Operating System via API") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Updating Image based Operating Systems is not supported", nil) + } + // Validate request attributes verr := apiRequest.Validate(os) if verr != nil { @@ -992,41 +1024,51 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating user data in Operating System creation request", verr) } - // Validate the tenant for which this OperatingSystem is being updated - tenant, err := common.GetTenantForOrg(ctx, nil, ush.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) + // Enforce ownership: both roles are evaluated independently so a dual-role + // caller is permitted if either role authorizes the operation. + ownedByTenant := tenant != nil && os.TenantID != nil && *os.TenantID == tenant.ID && os.InfrastructureProviderID == nil + ownedByProvider := false + if ip != nil && os.InfrastructureProviderID != nil { + if *os.InfrastructureProviderID != ip.ID { + logger.Warn().Msg("provider admin cannot update operating system owned by a different provider") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only update Operating Systems owned by their own provider", nil) } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) + ownedByProvider = true } - - // verify tenant matches - if os.TenantID == nil || tenant.ID != *os.TenantID { - logger.Warn().Msg("tenant in os does not belong to tenant in org") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant for OperatingSystem in request does not match tenant in org", nil) + if !ownedByProvider && !ownedByTenant { + if ip != nil && tenant == nil { + logger.Warn().Msg("provider admin cannot update tenant-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only update provider-owned Operating Systems", nil) + } + if tenant != nil && ip == nil { + logger.Warn().Msg("tenant admin cannot update provider-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant Admin can only update their own Operating Systems", nil) + } + logger.Warn().Msg("user does not have permission to update this operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to your tenant or infrastructure provider", nil) } - // check for name uniqueness for the tenant, ie, tenant cannot have another os with same name + // Check for name uniqueness within the owner's scope (provider or tenant). if apiRequest.Name != nil && *apiRequest.Name != os.Name { + uniquenessFilter := cdbm.OperatingSystemFilterInput{Names: []string{*apiRequest.Name}} + if os.InfrastructureProviderID != nil { + uniquenessFilter.InfrastructureProviderID = os.InfrastructureProviderID + } else { + uniquenessFilter.TenantIDs = []uuid.UUID{tenant.ID} + } oss, tot, serr := osDAO.GetAll( ctx, nil, - cdbm.OperatingSystemFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - Names: []string{*apiRequest.Name}, - }, + uniquenessFilter, cdbp.PageInput{}, nil, ) if serr != nil { - logger.Error().Err(serr).Msg("db error checking for name uniqueness of tenant os") + logger.Error().Err(serr).Msg("db error checking for name uniqueness of os") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update OperatingSystem due to DB error", nil) } if tot > 0 { - return cutil.NewAPIErrorResponse(c, http.StatusConflict, "Another Operating System with specified name already exists for Tenant", validation.Errors{ + return cutil.NewAPIErrorResponse(c, http.StatusConflict, fmt.Sprintf("Operating System: %s with specified name already exists", oss[0].ID.String()), validation.Errors{ "id": errors.New(oss[0].ID.String()), }) } @@ -1035,268 +1077,114 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { dbossas := []cdbm.OperatingSystemSiteAssociation{} sttsmap := map[uuid.UUID]*cdbm.TenantSite{} ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(ush.dbSession) - tsDAO := cdbm.NewTenantSiteDAO(ush.dbSession) - - // Verify Tenant Site Association - // Verify if Site is in Registered state - if os.Type == cdbm.OperatingSystemTypeImage { - dbossas, _, err = ossaDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{os.ID}, - }, - cdbp.PageInput{}, - []string{cdbm.SiteRelationName}, - ) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) - } - - // Get all TenantSite records for the Tenant - tss, _, err := tsDAO.GetAll( - ctx, - nil, - cdbm.TenantSiteFilterInput{ - TenantIDs: []uuid.UUID{tenant.ID}, - }, - cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, - nil, - ) - if err != nil { - logger.Error().Err(err).Msg("db error retrieving TenantSite records for Tenant") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Site associations for Tenant, DB error", nil) - } - - for _, ts := range tss { - cts := ts - sttsmap[ts.SiteID] = &cts - } - - // Verify if associated Site is not registered state - // Verify if current tenant not associated Site - for _, dbosa := range dbossas { - if dbosa.Site.Status != cdbm.SiteStatusRegistered { - logger.Warn().Msg(fmt.Sprintf("unable to update Operating System. Site: %s. Site is not in Registered state", dbosa.Site.Name)) - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to update Operating System, Associated Site: %s is not in Registered state", dbosa.Site.Name), nil) - } - // Validate the TenantSite exists for current tenant and this site - _, ok := sttsmap[dbosa.SiteID] - if !ok { - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Unable to update associate Operating System with Site: %s, Tenant does not have access to Site", dbosa.Site.Name), nil) - } - } + // start a database transaction + tx, err := cdb.BeginTx(ctx, ush.dbSession, &sql.TxOptions{}) + if err != nil { + logger.Error().Err(err).Msg("error updating os in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update Operating System", nil) } + txCommitted := false + defer common.RollbackTx(ctx, tx, &txCommitted) - // Save update status in DB - osStatus := cutil.GetPtr(cdbm.OperatingSystemStatusReady) - osStatusMessage := "Operating System has been updated and ready for use" + // Save update status in DB. + // Status goes to Syncing since updates are pushed asynchronously. + osStatus := cutil.GetPtr(cdbm.OperatingSystemStatusSyncing) + osStatusMessage := "received Operating System update request, syncing" if apiRequest.IsActive != nil && !*apiRequest.IsActive { osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusDeactivated) osStatusMessage = "Operating System has been deactivated" if apiRequest.DeactivationNote != nil && *apiRequest.DeactivationNote != "" { osStatusMessage += ". " + *apiRequest.DeactivationNote } - } else { - if apiRequest.IsActive != nil && *apiRequest.IsActive { - osStatusMessage = "Operating System has been reactivated and is ready for use" - } - if os.Type == cdbm.OperatingSystemTypeImage { - osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusSyncing) - osStatusMessage = "received Operating System update request, syncing" - } + } else if apiRequest.IsActive != nil && *apiRequest.IsActive { + osStatusMessage = "Operating System has been reactivated, syncing" } - // Values needed after the transaction closure - var uos *cdbm.OperatingSystem - var ssds []cdbm.StatusDetail - // timeoutResp captures any post-rollback work (terminating timed-out - // Temporal workflows) that must run after the transaction has been rolled - // back. It is invoked after the closure if non-nil. - var timeoutResp func() error - - err = cdb.WithTx(ctx, ush.dbSession, func(tx *cdb.Tx) error { - // When switching from inactive to active, clear deactivation note - deactivationNote := apiRequest.DeactivationNote - if apiRequest.IsActive != nil && *apiRequest.IsActive { - deactivationNote = nil - _, derr := osDAO.Clear(ctx, tx, cdbm.OperatingSystemClearInput{OperatingSystemId: osID, DeactivationNote: true}) - if derr != nil { - logger.Error().Err(derr).Msg("error updating/clearing Operating System in DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update/clear Operating System", nil) - } - } - 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, - IsCloudInit: apiRequest.IsCloudInit, - 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") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Operating System", nil) - } - uos = updatedOs - logger.Info().Msg("done updating os in DB") - - sdDAO := cdbm.NewStatusDetailDAO(ush.dbSession) - _, derr = sdDAO.CreateFromParams(ctx, tx, uos.ID.String(), *osStatus, &osStatusMessage) - if 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 update", nil) - } - - // get status details for the response - retssds, _, derr := sdDAO.GetAllByEntityID(ctx, tx, uos.ID.String(), nil, cutil.GetPtr(pagination.MaxPageSize), nil) - if derr != nil { - logger.Error().Err(derr).Msg("error retrieving Status Details for os from DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve Status Details for Operating System", nil) + // When switching from inactive to active, clear deactivation note + deactivationNote := apiRequest.DeactivationNote + if apiRequest.IsActive != nil && *apiRequest.IsActive { + deactivationNote = nil + _, err := osDAO.Clear(ctx, tx, cdbm.OperatingSystemClearInput{OperatingSystemId: osID, DeactivationNote: true}) + if err != nil { + logger.Error().Err(err).Msg("error updating/clearing Operating System in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update/clear Operating System", nil) } - ssds = retssds - - // If OS is Image based, update version too - // Retrieve Operating System Associations details - // Trigger workflows to sync Image based Operating System with various Sites - if uos.Type == cdbm.OperatingSystemTypeImage { - for _, dbossa := range dbossas { - _, derr := ossaDAO.Update( - ctx, - tx, - cdbm.OperatingSystemSiteAssociationUpdateInput{ - OperatingSystemSiteAssociationID: dbossa.ID, - Status: cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing), - }, - ) - if 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) - } - - // Create Status details - _, derr = sdDAO.CreateFromParams(ctx, tx, dbossa.ID.String(), *cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusSyncing), - cutil.GetPtr("received Operating System Association update request, syncing")) - if 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) - } - - // Update Operating System Association version - updatedOssa, derr := ossaDAO.GenerateAndUpdateVersion(ctx, tx, dbossa.ID) - if derr != nil { - logger.Error().Err(derr).Msg("error updating version for updated Operating System Association") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to set version for updated Operating System Site Association, DB error", nil) - } - - // Get the temporal client for the site we are working with. - stc, derr := ush.scp.GetClientByID(dbossa.SiteID) - if derr != nil { - logger.Error().Err(derr).Msg("failed to retrieve Temporal client for Site") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve client for Site", nil) - } - - updateOsRequest := apiRequest.ToProto(uos, tenant.Org) + } + uos, err := 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, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + UserData: apiRequest.UserData, + IsCloudInit: apiRequest.IsCloudInit, + AllowOverride: apiRequest.AllowOverride, + PhoneHomeEnabled: apiRequest.PhoneHomeEnabled, + IsActive: apiRequest.IsActive, + DeactivationNote: deactivationNote, + Status: osStatus, + }) + if err != nil { + logger.Error().Err(err).Msg("error updating Operating System in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update Operating System", nil) + } + logger.Info().Msg("done updating os in DB") - workflowOptions := temporalClient.StartWorkflowOptions{ - ID: "image-os-update-" + updatedOssa.SiteID.String() + "-" + uos.ID.String() + "-" + *updatedOssa.Version, - WorkflowExecutionTimeout: cutil.WorkflowExecutionTimeout, - TaskQueue: queue.SiteTaskQueue, - } + sdDAO := cdbm.NewStatusDetailDAO(ush.dbSession) + _, serr := sdDAO.CreateFromParams(ctx, tx, uos.ID.String(), *osStatus, &osStatusMessage) + if serr != nil { + logger.Error().Err(serr).Msg("error creating Status Detail DB entry") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create status detail for Operating System update", nil) + } - logger.Info().Str("Site ID", dbossa.SiteID.String()).Msg("triggering Image based Operating System update workflow ") - - // Workflow execution wrapped in a function literal so `defer cancel()` - // scopes to this iteration; otherwise the deferred cancels would pile - // up until the WithTx closure returns. - iterErr := func() *cutil.APIError { - // Add context deadlines - wfCtx, cancel := context.WithTimeout(ctx, cutil.WorkflowContextTimeout) - defer cancel() - - // Trigger Site workflow - we, wferr := stc.ExecuteWorkflow(wfCtx, workflowOptions, "UpdateOsImage", updateOsRequest) - if wferr != nil { - logger.Error().Err(wferr).Msg("failed to synchronously start Temporal workflow to update Operating System") - return cutil.NewAPIError(http.StatusInternalServerError, fmt.Sprintf("Failed start sync workflow to update Operating System on Site: %s", wferr), nil) - } + // get status details for the response + ssds, _, err := sdDAO.GetAllByEntityID(ctx, tx, uos.ID.String(), nil, cutil.GetPtr(pagination.MaxPageSize), nil) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Status Details for os from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Status Details for Operating System", nil) + } - wid := we.GetID() - logger.Info().Str("Workflow ID", wid).Msg("executed synchronous update Operating System workflow") - - // Block until the workflow has completed and returned success/error. - wferr = we.Get(wfCtx, nil) - if wferr != nil { - var timeoutErr *tp.TimeoutError - if errors.As(wferr, &timeoutErr) || wferr == context.DeadlineExceeded || wfCtx.Err() != nil { - logger.Error().Err(wferr).Msg("failed to update Operating System, timeout occurred executing workflow on Site.") - timeoutResp = func() error { - return common.TerminateWorkflowOnTimeOut(c, logger, stc, wid, wferr, "OperatingSystem", "Update") - } - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to update Operating System, timeout occurred executing workflow on Site", nil) - } - code, uwerr := common.UnwrapWorkflowError(wferr) - logger.Error().Err(uwerr).Msg("failed to synchronously execute Temporal workflow to update Operating System") - return cutil.NewAPIError(code, fmt.Sprintf("Failed to execute sync workflow to update Operating System on Site: %s", uwerr), nil) - } - logger.Info().Str("Workflow ID", wid).Str("Site ID", dbossa.SiteID.String()).Msg("completed synchronous update Operating System workflow") - return nil - }() - if iterErr != nil { - return iterErr - } - } + // Load existing site associations for the response and trigger async sync workflow. + dbossas, _, err = ossaDAO.GetAll(ctx, tx, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{uos.ID}}, + cdbp.PageInput{}, []string{cdbm.SiteRelationName}) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } - // Re-read the site associations so the response reflects the - // status/version writes we just made (the dbossas slice loaded - // pre-tx is now stale). - refreshedOssas, _, derr := ossaDAO.GetAll( - ctx, - tx, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{uos.ID}, - }, - cdbp.PageInput{ - Limit: cutil.GetPtr(cdbp.TotalLimit), - }, - []string{cdbm.SiteRelationName}, - ) - if derr != nil { - logger.Error().Err(derr).Msg("error refreshing Operating System Site associations from DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve updated Operating System Site associations from DB", nil) - } - dbossas = refreshedOssas + if len(dbossas) > 0 && cdbm.IsIPXEType(os.Type) { + targetSiteIDs := make([]uuid.UUID, len(dbossas)) + for i, dbossa := range dbossas { + targetSiteIDs[i] = dbossa.SiteID } - return nil - }) - // The wrapping `if err != nil` ensures real tx-helper errors (commit / - // rollback failures that wrap into something other than the cutil.APIError - // marker we returned for the timeout case) are surfaced via HandleTxError, - // while the timeout-case APIError falls through to the timeoutResp call. - if err != nil { - var apiErr *cutil.APIError - if !errors.As(err, &apiErr) || timeoutResp == nil { - return common.HandleTxError(c, logger, err, "Failed to update Operating System due to DB transaction error") + // Trigger async workflow before committing so a failure to enqueue rolls back the transaction. + wid, werr := osWorkflow.ExecuteCreateOrUpdateOperatingSystemByIDWorkflow(ctx, ush.tc, targetSiteIDs, uos.ID) + if werr != nil { + logger.Error().Err(werr).Interface("Site IDs", targetSiteIDs).Msg("failed to trigger CreateOrUpdateOperatingSystemByID workflow for update") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to trigger Oprating System update on Sites", nil) } + logger.Info().Str("Workflow ID", *wid).Interface("Site IDs", targetSiteIDs).Msg("triggered async CreateOrUpdateOperatingSystemByID workflow for update on Sites") } - if timeoutResp != nil { - return timeoutResp() + + // Commit transaction. + err = tx.Commit() + if err != nil { + logger.Error().Err(err).Msg("error updating OperatingSystem in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to update OperatingSystem", nil) } + txCommitted = true // Send response apiOperatingSystem := model.NewAPIOperatingSystem(uos, ssds, dbossas, sttsmap) @@ -1346,22 +1234,9 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) } - // Validate org - ok, err := auth.ValidateOrgMembership(dbUser, org) - if !ok { - if err != nil { - logger.Error().Err(err).Msg("error validating org membership for User in request") - } else { - logger.Warn().Msg("could not validate org membership for user, access denied") - } - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) - } - - // Validate role, only Tenant Admins are allowed to delete OperatingSystem - ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole) - if !ok { - logger.Warn().Msg("user does not have Tenant Admin role, access denied") - return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil) + ip, tenant, apiError := common.IsProviderOrTenant(ctx, logger, dsh.dbSession, org, dbUser, false, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) } // Get operating system ID from URL param @@ -1375,17 +1250,6 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Operating System ID in URL", nil) } - // Validate the tenant for which this OperatingSystem is being updated - tenant, err := common.GetTenantForOrg(ctx, nil, dsh.dbSession, org) - if err != nil { - if err == common.ErrOrgTenantNotFound { - logger.Warn().Err(err).Msg("Org does not have a Tenant associated") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Org does not have a Tenant associated", nil) - } - logger.Error().Err(err).Msg("unable to retrieve tenant for org") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant for org", nil) - } - // Check that operating system exists osDAO := cdbm.NewOperatingSystemDAO(dsh.dbSession) os, err := osDAO.GetByID(ctx, nil, osID, nil) @@ -1397,33 +1261,50 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Could not retrieve Operating System to delete", nil) } - // verify tenant matches - if os.TenantID == nil || tenant.ID != *os.TenantID { - logger.Warn().Msg("tenant in os does not belong to tenant in org") - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Tenant for Operating System in request does not match tenant in org", nil) + // Enforce ownership: both roles are evaluated independently so a dual-role + // caller is permitted if either role authorizes the operation. + ownedByTenantD := tenant != nil && os.TenantID != nil && *os.TenantID == tenant.ID && os.InfrastructureProviderID == nil + ownedByProviderD := false + if ip != nil && os.InfrastructureProviderID != nil { + if *os.InfrastructureProviderID != ip.ID { + logger.Warn().Msg("provider admin cannot delete operating system owned by a different provider") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only delete Operating Systems owned by their own provider", nil) + } + ownedByProviderD = true + } + if !ownedByProviderD && !ownedByTenantD { + if ip != nil && tenant == nil { + logger.Warn().Msg("provider admin cannot delete tenant-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Provider Admin can only delete provider-owned Operating Systems", nil) + } + if tenant != nil && ip == nil { + logger.Warn().Msg("tenant admin cannot delete provider-owned operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant Admin can only delete their own Operating Systems", nil) + } + logger.Warn().Msg("user does not have permission to delete this operating system") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Operating System does not belong to your tenant or infrastructure provider", nil) } - // Verify if tenant associated with Site in case of Image based OS - // Verify Tenant Site Association - // Verify if Site is in Registered state + // Retrieve site associations for this Operating System (both iPXE and Image types + // may have associations that need per-site workflow propagation on delete). ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dsh.dbSession) - ossasToDelete := []cdbm.OperatingSystemSiteAssociation{} - if os.Type == cdbm.OperatingSystemTypeImage { - ossasToDelete, _, err = ossaDAO.GetAll( - ctx, - nil, - cdbm.OperatingSystemSiteAssociationFilterInput{ - OperatingSystemIDs: []uuid.UUID{os.ID}, - }, - cdbp.PageInput{}, - []string{cdbm.SiteRelationName}, - ) - if err != nil { - logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") - return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) - } + ossasToDelete, _, err := ossaDAO.GetAll( + ctx, + nil, + cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{os.ID}, + }, + cdbp.PageInput{}, + []string{cdbm.SiteRelationName}, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Operating System Site associations from DB") + 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 image-based OS, verify all associated sites are in Registered state + // TODO: Do we not want this for iPXE-based OSes? + 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())) @@ -1435,7 +1316,11 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { // verify no instances are using the os isDAO := cdbm.NewInstanceDAO(dsh.dbSession) - instances, _, err := isDAO.GetAll(ctx, nil, cdbm.InstanceFilterInput{TenantIDs: []uuid.UUID{tenant.ID}, OperatingSystemIDs: []uuid.UUID{os.ID}}, paginator.PageInput{}, nil) + instanceFilter := cdbm.InstanceFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}} + if tenant != nil { + instanceFilter.TenantIDs = []uuid.UUID{tenant.ID} + } + instances, _, err := isDAO.GetAll(ctx, nil, instanceFilter, paginator.PageInput{}, nil) if err != nil { logger.Error().Err(err).Msg("error retrieving Instances for Operating System from DB") return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Instances for deleting operatingsystem", nil) @@ -1446,161 +1331,140 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Operating System is being used by one or more Instances and cannot be deleted", nil) } - // timeoutResp captures any post-rollback work (terminating timed-out - // Temporal workflows) that must run after the transaction has been rolled - // back. It is invoked after the closure if non-nil. - var timeoutResp func() error + // Start a db tx + tx, err := cdb.BeginTx(ctx, dsh.dbSession, &sql.TxOptions{}) + if err != nil { + logger.Error().Err(err).Msg("unable to start transaction") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error deleting Operating System", nil) + } + // this variable is used in cleanup actions to indicate if this transaction committed + txCommitted := false + defer common.RollbackTx(ctx, tx, &txCommitted) - err = cdb.WithTx(ctx, dsh.dbSession, func(tx *cdb.Tx) error { - // acquire an advisory lock on the Operating System on which there could be contention - // this lock is released when the transaction commits or rollsback - derr := tx.TryAcquireAdvisoryLock(ctx, cdb.GetAdvisoryLockIDFromString(os.ID.String()), nil) - if derr != nil { - logger.Error().Err(derr).Msg("Failed to acquire advisory lock on Operating System") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to delete Operating System, could not acquire data store lock on Operating System", nil) + // acquire an advisory lock on the Operating System on which there could be contention + // this lock is released when the transaction commits or rollsback + err = tx.TryAcquireAdvisoryLock(ctx, cdb.GetAdvisoryLockIDFromString(os.ID.String()), nil) + if err != nil { + logger.Error().Err(err).Msg("Failed to acquire advisory lock on Operating System") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System, could not acquire data store lock on Operating System", nil) + } + + // Propagate the delete to associated sites (iPXE via DeleteOperatingSystem, Image via DeleteOsImage). + if len(ossasToDelete) > 0 { + // Update Operating System to set status to Deleting + _, err = osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{OperatingSystemId: os.ID, Status: cutil.GetPtr(cdbm.OperatingSystemStatusDeleting)}) + if err != nil { + logger.Error().Err(err).Msg("error updating Operating System in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System", nil) } - // Verify if OS is image based - if os.Type == cdbm.OperatingSystemTypeImage { + sdDAO := cdbm.NewStatusDetailDAO(dsh.dbSession) + _, err = sdDAO.CreateFromParams(ctx, tx, os.ID.String(), cdbm.OperatingSystemStatusDeleting, cutil.GetPtr("received request for deletion, pending processing")) + if err != nil { + logger.Error().Err(err).Msg("error creating Status Detail DB entry") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Status Detail for Operating System", nil) + } - // Update Operating System to set status to Deleting - _, derr := osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{OperatingSystemId: os.ID, Status: cutil.GetPtr(cdbm.OperatingSystemStatusDeleting)}) - if derr != nil { - logger.Error().Err(derr).Msg("error updating Operating System in DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to delete Operating System", nil) - } + for _, ossa := range ossasToDelete { + if ossa.Status != cdbm.OperatingSystemSiteAssociationStatusDeleting { + _, err = ossaDAO.Update(ctx, tx, + cdbm.OperatingSystemSiteAssociationUpdateInput{ + OperatingSystemSiteAssociationID: ossa.ID, + Status: cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusDeleting), + }) + if err != nil { + logger.Error().Err(err).Msg("error updating Operating System Association in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating Systems", nil) + } - // Create status detail - sdDAO := cdbm.NewStatusDetailDAO(dsh.dbSession) - // create a status detail record for the Operating System - _, derr = sdDAO.CreateFromParams(ctx, tx, os.ID.String(), cdbm.OperatingSystemStatusDeleting, cutil.GetPtr("received request for deletion, pending processing")) - if 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) + _, err = sdDAO.CreateFromParams(ctx, tx, ossa.ID.String(), cdbm.OperatingSystemSiteAssociationStatusDeleting, cutil.GetPtr("received request for deletion, pending processing")) + if err != nil { + logger.Error().Err(err).Msg("error creating Status Detail DB entry") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to create Status Detail for Operating System Association", nil) + } } + } + } - // Update Status Deleting for Operating System Association - for _, ossa := range ossasToDelete { - if ossa.Status != cdbm.OperatingSystemSiteAssociationStatusDeleting { - // Update Operating System Association to set status to Deleting - _, derr := ossaDAO.Update( - ctx, - tx, - cdbm.OperatingSystemSiteAssociationUpdateInput{ - OperatingSystemSiteAssociationID: ossa.ID, - Status: cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusDeleting), - }, - ) - if derr != nil { - logger.Error().Err(derr).Msg("error updating Operating System Association in DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to delete Operating Systems", nil) - } - - // create a status detail record for the Operating System Association - _, derr = sdDAO.CreateFromParams(ctx, tx, ossa.ID.String(), cdbm.OperatingSystemSiteAssociationStatusDeleting, cutil.GetPtr("received request for deletion, pending processing")) - if 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) - } - - // Get the temporal client for the site we are working with. - stc, derr := dsh.scp.GetClientByID(ossa.SiteID) - if derr != nil { - logger.Error().Err(derr).Msg("failed to retrieve Temporal client for Site") - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to retrieve client for Site", nil) - } + // Soft-delete the OS if it has no site associations (legacy iPXE, or image-based with + // associations already cleaned up by the workflows above). + if len(ossasToDelete) == 0 { + err = osDAO.Delete(ctx, tx, os.ID) + if err != nil { + logger.Error().Msg("error deleting Operating System record in DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error deleting Operating System record in DB", nil) + } + } - // Prepare the delete/release request workflow object - deleteOsRequest := os.ToDeletionRequestProto(tenant.Org) + // Trigger async workflow before committing so a failure to enqueue rolls back the transaction. + if len(ossasToDelete) > 0 && cdbm.IsIPXEType(os.Type) { + targetSiteIDs := make([]uuid.UUID, len(ossasToDelete)) + for i, ossa := range ossasToDelete { + targetSiteIDs[i] = ossa.SiteID + } + wid, werr := osWorkflow.ExecuteDeleteOperatingSystemByIDWorkflow(ctx, dsh.tc, targetSiteIDs, os.ID) + if werr != nil { + logger.Error().Err(werr).Interface("Site IDs", targetSiteIDs).Msg("failed to trigger DeleteOperatingSystemByID workflow for delete") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to trigger Operating System deletion on Sites", nil) + } + logger.Info().Str("Workflow ID", *wid).Interface("Site IDs", targetSiteIDs).Msg("triggered async DeleteOperatingSystemByID workflow for delete on Sites") + } - workflowOptions := temporalClient.StartWorkflowOptions{ - ID: "image-os-delete-" + ossa.SiteID.String() + "-" + os.ID.String() + "-" + *ossa.Version, - WorkflowExecutionTimeout: cutil.WorkflowExecutionTimeout, - TaskQueue: queue.SiteTaskQueue, - } + // Commit transaction. + err = tx.Commit() + if err != nil { + logger.Error().Err(err).Msg("error committing Operating System transaction to DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System", nil) + } + txCommitted = true - logger.Info().Msg("triggering Operating System delete workflow") - - // Workflow execution wrapped in a function literal so `defer cancel()` - // scopes to this iteration; otherwise the deferred cancels would pile - // up until the WithTx closure returns. - iterErr := func() *cutil.APIError { - wfCtx, cancel := context.WithTimeout(ctx, cutil.WorkflowContextTimeout) - defer cancel() - - // Trigger Site workflow to delete Image based OperatingSystem - we, wferr := stc.ExecuteWorkflow(wfCtx, workflowOptions, "DeleteOsImage", deleteOsRequest) - if wferr != nil { - logger.Error().Err(wferr).Msg("failed to synchronously start Temporal workflow to delete Operating System") - return cutil.NewAPIError(http.StatusInternalServerError, fmt.Sprintf("Failed to start sync workflow to delete Operating System on Site: %s", wferr), nil) - } - - wid := we.GetID() - logger.Info().Str("Workflow ID", wid).Msg("executed synchronous delete Operating System workflow") - - // Execute the workflow synchronously - wferr = we.Get(wfCtx, nil) - // Handle skippable errors - if wferr != nil { - // If this was a 404 back from NICo, we can treat the object as already having been deleted and allow things to proceed. - var applicationErr *tp.ApplicationError - if errors.As(wferr, &applicationErr) && slices.Contains(swe.ObjectNotFoundErrTypes(), applicationErr.Type()) { - logger.Warn().Msg(swe.ErrTypeNICoObjectNotFound + " received from Site") - // Reset error to nil - wferr = nil - } - } - - // Check if err is still nil now that we've handled any skippable errors. - if wferr != nil { - var timeoutErr *tp.TimeoutError - if errors.As(wferr, &timeoutErr) || wferr == context.DeadlineExceeded || wfCtx.Err() != nil { - logger.Error().Err(wferr).Msg("failed to delete Operating System, timeout occurred executing workflow on Site.") - timeoutResp = func() error { - return common.TerminateWorkflowOnTimeOut(c, logger, stc, wid, wferr, "OperatingSystem", "Delete") - } - return cutil.NewAPIError(http.StatusInternalServerError, "Failed to delete Operating System, timeout occurred executing workflow on Site", nil) - } - - code, uwerr := common.UnwrapWorkflowError(wferr) - logger.Error().Err(uwerr).Msg("failed to synchronously execute Temporal workflow to delete Operating System") - return cutil.NewAPIError(code, fmt.Sprintf("Failed to execute sync workflow to delete Operating System on Site: %s", uwerr), nil) - } - - logger.Info().Str("Workflow ID", wid).Msg("completed synchronous delete Operating System workflow") - return nil - }() - if iterErr != nil { - return iterErr - } + // Image-based OSes still use the synchronous per-site workflow pattern (post-commit). + if len(ossasToDelete) > 0 && os.Type == cdbm.OperatingSystemTypeImage { + for _, ossa := range ossasToDelete { + if ossa.Status == cdbm.OperatingSystemSiteAssociationStatusDeleting { + continue + } + stc, serr := dsh.scp.GetClientByID(ossa.SiteID) + if serr != nil { + logger.Error().Err(serr).Msg("failed to retrieve Temporal client for Site") + continue + } + workflowOptions := temporalClient.StartWorkflowOptions{ + ID: "image-os-delete-" + ossa.SiteID.String() + "-" + os.ID.String() + "-" + *ossa.Version, + TaskQueue: queue.SiteTaskQueue, + } + deleteOsRequest := &cwssaws.DeleteOsImageRequest{ + Id: &cwssaws.UUID{Value: os.ID.String()}, + TenantOrganizationId: tenant.Org, + } + we, werr := stc.ExecuteWorkflow(ctx, workflowOptions, "DeleteOsImage", deleteOsRequest) + if werr != nil { + logger.Error().Err(werr).Msg("failed to start DeleteOsImage workflow") + continue + } + werr = we.Get(ctx, nil) + if werr != nil { + var applicationErr *tp.ApplicationError + if errors.As(werr, &applicationErr) && applicationErr.Type() == swe.ErrTypeCarbideObjectNotFound { + werr = 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 { - derr := osDAO.Delete(ctx, tx, os.ID) - if derr != nil { - logger.Error().Err(derr).Msg("error deleting Operating System record in DB") - return cutil.NewAPIError(http.StatusInternalServerError, "Error deleting Operating System record in DB", nil) + if werr != nil { + var timeoutErr *tp.TimeoutError + if errors.As(werr, &timeoutErr) { + logger.Error().Err(werr).Msg("failed to delete Operating System, timeout occurred executing workflow on Site.") + newctx := context.Background() + serr := stc.TerminateWorkflow(newctx, we.GetID(), "", "timeout occurred executing delete Operating System workflow") + if serr != nil { + logger.Error().Err(serr).Msg("failed to execute terminate Temporal workflow for deleting Operating System") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to terminate synchronous Operating System delete workflow after timeout, Cloud and Site data may be de-synced: %s", serr), nil) + } + logger.Info().Str("Workflow ID", we.GetID()).Msg("initiated terminate synchronous delete Operating System workflow successfully") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to delete Operating System, timeout occurred executing workflow on Site: %s", werr), nil) + } + logger.Error().Err(werr).Str("Workflow ID", we.GetID()).Msg("DeleteOsImage workflow failed") } } - - return nil - }) - // The wrapping `if err != nil` ensures real tx-helper errors (commit / - // rollback failures that wrap into something other than the cutil.APIError - // marker we returned for the timeout case) are surfaced via HandleTxError, - // while the timeout-case APIError falls through to the timeoutResp call. - if err != nil { - var apiErr *cutil.APIError - if !errors.As(err, &apiErr) || timeoutResp == nil { - return common.HandleTxError(c, logger, err, "Failed to delete Operating System due to DB transaction error") - } - } - if timeoutResp != nil { - return timeoutResp() } // Create response @@ -1608,3 +1472,21 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return c.String(http.StatusAccepted, "Deletion request was accepted") } + +// getTenantSiteIDs returns the IDs of all sites the given tenant has access to. +func getTenantSiteIDs(ctx context.Context, dbSession *db.Session, tenantID uuid.UUID) ([]uuid.UUID, error) { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, err := tsDAO.GetAll(ctx, nil, + cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenantID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + return nil, err + } + ids := make([]uuid.UUID, len(tss)) + for i, ts := range tss { + ids[i] = ts.SiteID + } + return ids, nil +} diff --git a/rest-api/api/pkg/api/handler/operatingsystem_ownership_test.go b/rest-api/api/pkg/api/handler/operatingsystem_ownership_test.go new file mode 100644 index 0000000000..b39d80f537 --- /dev/null +++ b/rest-api/api/pkg/api/handler/operatingsystem_ownership_test.go @@ -0,0 +1,961 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file extends operatingsystem_test.go with tests that validate the +// ownership model (TenantID vs InfrastructureProviderID) and role-based +// access-control enforcement introduced alongside the bi-directional sync +// feature. Each test function is self-contained and uses a fresh schema. +// +// Roles under test +// - ipUser — FORGE_PROVIDER_ADMIN only +// - tnUser — FORGE_TENANT_ADMIN only +// - dualUser — both roles (either role may authorize the operation) +// +// Ownership invariants verified +// - Provider Admin → InfrastructureProviderID set, TenantID nil +// - Tenant Admin → TenantID set, InfrastructureProviderID nil +// - Dual-role → permitted if either role authorizes the action; +// when both allow it, Provider Admin takes priority +// for ownership assignment +// +// Cross-ownership visibility (GetAll / GetByID) +// - Provider Admin sees only provider-owned OSes (none from tenants). +// - Tenant Admin sees own OSes + provider-owned OSes that have site +// associations at sites accessible to the tenant. +// +// Mutation enforcement (Update / Delete) +// - Provider Admin can mutate only provider-owned OSes. +// - Tenant Admin can mutate only tenant-owned OSes. +// - Dual-role user is permitted if either role authorizes the action. + +package handler + +import ( + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site" + "github.com/NVIDIA/infra-controller/rest-api/common/pkg/otelecho" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + tmocks "go.temporal.io/sdk/mocks" +) + +// ─── shared setup helpers ──────────────────────────────────────────────────── + +// ownershipTestEnv contains all DB fixtures that the ownership-related tests +// share. Each test function calls newOwnershipTestEnv and receives a freshly +// reset schema. +type ownershipTestEnv struct { + dbSession *cdb.Session + // Shared org that has both an InfrastructureProvider and a Tenant. + // Users in this org can carry either or both roles. + sharedOrg string + ip *cdbm.InfrastructureProvider + tenant *cdbm.Tenant + site *cdbm.Site // registered site belonging to ip + + // Users + ipUser *cdbm.User // FORGE_PROVIDER_ADMIN only + tnUser *cdbm.User // FORGE_TENANT_ADMIN only + dualUser *cdbm.User // both roles + + // Temporal mocks (permissive — match any workflow invocation) + tempClient *tmocks.Client + scp *sc.ClientPool +} + +func newOwnershipTestEnv(t *testing.T) *ownershipTestEnv { + t.Helper() + + dbSession := testMachineInitDB(t) + t.Cleanup(func() { dbSession.Close() }) + common.TestSetupSchema(t, dbSession) + + sharedOrg := "shared-org" + + ip := testMachineBuildInfrastructureProvider(t, dbSession, sharedOrg, "shared-ip") + require.NotNil(t, ip) + + tenant := testMachineBuildTenant(t, dbSession, sharedOrg, "shared-tenant") + require.NotNil(t, tenant) + + site := testMachineBuildSite(t, dbSession, ip, "shared-site", cdbm.SiteStatusRegistered) + require.NotNil(t, site) + + // TenantSite so tenant users can reference the site. + tnu := testMachineBuildUser(t, dbSession, uuid.NewString(), + []string{sharedOrg}, []string{"FORGE_TENANT_ADMIN"}) + ts := testBuildTenantSiteAssociation(t, dbSession, sharedOrg, tenant.ID, site.ID, tnu.ID) + require.NotNil(t, ts) + + ipUser := testMachineBuildUser(t, dbSession, uuid.NewString(), + []string{sharedOrg}, []string{"FORGE_PROVIDER_ADMIN"}) + dualUser := testMachineBuildUser(t, dbSession, uuid.NewString(), + []string{sharedOrg}, []string{"FORGE_PROVIDER_ADMIN", "FORGE_TENANT_ADMIN"}) + + // Permissive Temporal mock: accepts any ExecuteWorkflow call so that tests + // exercising the success path don't have to enumerate every signature. + wrun := &tmocks.WorkflowRun{} + wrun.On("GetID").Return("test-wf-id") + wrun.On("Get", mock.Anything, mock.Anything).Return(nil) + + tempClient := &tmocks.Client{} + tempClient.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, + mock.Anything, mock.Anything).Return(wrun, nil) + + cfg := common.GetTestConfig() + tcfg, _ := cfg.GetTemporalConfig() + scp := sc.NewClientPool(tcfg) + + // Per-site Temporal client (permissive). + siteMock := &tmocks.Client{} + siteMock.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, + mock.Anything).Return(wrun, nil) + siteMock.On("TerminateWorkflow", mock.Anything, mock.Anything, mock.Anything, + mock.Anything).Return(nil) + scp.IDClientMap[site.ID.String()] = siteMock + + return &ownershipTestEnv{ + dbSession: dbSession, + sharedOrg: sharedOrg, + ip: ip, + tenant: tenant, + site: site, + ipUser: ipUser, + tnUser: tnu, + dualUser: dualUser, + tempClient: tempClient, + scp: scp, + } +} + +// execCreateOS posts a Create request and returns the response recorder. +func (e *ownershipTestEnv) execCreateOS(t *testing.T, user *cdbm.User, body interface{}) *httptest.ResponseRecorder { + t.Helper() + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + tracer, traceCtx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(rawBody))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName") + ec.SetParamValues(e.sharedOrg) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(traceCtx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := CreateOperatingSystemHandler{ + dbSession: e.dbSession, + tc: e.tempClient, + cfg: cfg, + scp: e.scp, + } + require.NoError(t, h.Handle(ec)) + return rec +} + +// otelTraceCtx returns a no-op tracer and a context for use in handler tests. +func otelTraceCtx(t *testing.T) (interface{}, context.Context) { + t.Helper() + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, context.Background()) + return tracer, ctx +} + +// ─── Create: ownership assignment ──────────────────────────────────────────── + +// TestOperatingSystemHandler_Create_ProviderAndTenantOwnership verifies that +// the Create handler assigns ownership correctly based on the caller's role: +// +// - Provider Admin → InfrastructureProviderID = provider's ID, TenantID = nil +// - Tenant Admin → TenantID = tenant's ID, InfrastructureProviderID = nil +// - Dual-role user → permitted if either role authorizes the action; +// when both allow it, provider ownership takes priority +// +// The test also covers the "new" iPXE OS type (template-based with parameters +// and artifacts) to ensure those fields round-trip correctly. +func TestOperatingSystemHandler_Create_ProviderAndTenantOwnership(t *testing.T) { + env := newOwnershipTestEnv(t) + + ipxeScript := "ipxe-script-content" + templateName := "raw-ipxe" + scopeGlobal := cdbm.OperatingSystemScopeGlobal + + // template-based request reused for several sub-tests. + templateBody := model.APIOperatingSystemCreateRequest{ + Name: "tmpl-os-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + } + + tests := []struct { + name string + user *cdbm.User + body model.APIOperatingSystemCreateRequest + wantStatus int + wantProviderID *uuid.UUID // nil means we don't assert + wantTenantID *uuid.UUID // nil means we don't assert + wantProviderNil bool + wantTenantNil bool + }{ + { + name: "provider admin raw iPXE → forbidden (must use template)", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "prov-ipxe-" + uuid.NewString(), + IpxeScript: &ipxeScript, + }, + wantStatus: http.StatusForbidden, + }, + { + name: "provider admin template iPXE → provider-owned", + user: env.ipUser, + body: templateBody, + wantStatus: http.StatusCreated, + wantProviderID: &env.ip.ID, + wantTenantNil: true, + }, + { + name: "tenant admin raw iPXE → tenant-owned", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-ipxe-" + uuid.NewString(), + IpxeScript: &ipxeScript, + }, + wantStatus: http.StatusCreated, + wantTenantID: &env.tenant.ID, + wantProviderNil: true, + }, + { + name: "tenant admin template iPXE → tenant-owned", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-tmpl-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + wantStatus: http.StatusCreated, + wantTenantID: &env.tenant.ID, + wantProviderNil: true, + }, + { + name: "dual-role user raw iPXE → tenant-owned (tenant role authorizes)", + user: env.dualUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "dual-ipxe-" + uuid.NewString(), + IpxeScript: &ipxeScript, + }, + wantStatus: http.StatusCreated, + wantTenantID: &env.tenant.ID, + wantProviderNil: true, + }, + { + name: "dual-role user template iPXE → provider-owned", + user: env.dualUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "dual-tmpl-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + wantStatus: http.StatusCreated, + wantProviderID: &env.ip.ID, + wantTenantNil: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := env.execCreateOS(t, tc.user, tc.body) + assert.Equal(t, tc.wantStatus, rec.Code, "response body: %s", rec.Body.String()) + if rec.Code != http.StatusCreated { + return + } + + var rsp model.APIOperatingSystem + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rsp)) + + if tc.wantProviderID != nil { + require.NotNil(t, rsp.InfrastructureProviderID, + "expected InfrastructureProviderID to be set") + assert.Equal(t, tc.wantProviderID.String(), *rsp.InfrastructureProviderID) + } + if tc.wantProviderNil { + assert.Nil(t, rsp.InfrastructureProviderID, + "expected InfrastructureProviderID to be nil") + } + if tc.wantTenantID != nil { + require.NotNil(t, rsp.TenantID, "expected TenantID to be set") + assert.Equal(t, tc.wantTenantID.String(), *rsp.TenantID) + } + if tc.wantTenantNil { + assert.Nil(t, rsp.TenantID, "expected TenantID to be nil") + } + + // Verify iPXE parameters and artifacts round-trip for template OS. + if tc.body.IpxeTemplateId != nil { + assert.Equal(t, tc.body.IpxeTemplateId, rsp.IpxeTemplateId) + if len(tc.body.IpxeTemplateParameters) > 0 { + require.Len(t, rsp.IpxeTemplateParameters, len(tc.body.IpxeTemplateParameters)) + assert.Equal(t, tc.body.IpxeTemplateParameters[0].Name, rsp.IpxeTemplateParameters[0].Name) + } + if len(tc.body.IpxeTemplateArtifacts) > 0 { + require.Len(t, rsp.IpxeTemplateArtifacts, len(tc.body.IpxeTemplateArtifacts)) + assert.Equal(t, tc.body.IpxeTemplateArtifacts[0].Name, rsp.IpxeTemplateArtifacts[0].Name) + assert.Equal(t, tc.body.IpxeTemplateArtifacts[0].URL, rsp.IpxeTemplateArtifacts[0].URL) + } + } + }) + } +} + +// ─── GetAll: cross-ownership visibility ────────────────────────────────────── + +// TestOperatingSystemHandler_GetAll_CrossOwnership verifies role-based +// visibility rules: +// - Provider Admin sees only provider-owned OSes. +// - Tenant Admin sees own OSes + provider-owned OSes at accessible sites. +// - Dual-role user sees the union. +func TestOperatingSystemHandler_GetAll_CrossOwnership(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // Seed one provider-owned OS with a site association so the tenant can see it. + provOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "synced-from-core", + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe\nchain http://boot.example.com"), + IpxeOsScope: cutil.GetPtr(cdbm.OperatingSystemScopeLocal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(env.dbSession) + _, err = ossaDAO.Create(ctx, nil, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: provOS.ID, + SiteID: env.site.ID, + Status: cdbm.OperatingSystemSiteAssociationStatusSynced, + }) + require.NoError(t, err) + + // Seed one tenant-owned OS. + tnOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tenant-os", + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe\nboot"), + IpxeOsScope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + + execGetAll := func(t *testing.T, user *cdbm.User) []model.APIOperatingSystem { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName") + ec.SetParamValues(env.sharedOrg) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := GetAllOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + } + require.NoError(t, h.Handle(ec)) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var rsp []model.APIOperatingSystem + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rsp)) + return rsp + } + + t.Run("provider admin sees only provider-owned OS", func(t *testing.T) { + oss := execGetAll(t, env.ipUser) + ids := make([]string, len(oss)) + for i, o := range oss { + ids[i] = o.ID + } + assert.Contains(t, ids, provOS.ID.String()) + assert.NotContains(t, ids, tnOS.ID.String(), "provider admin must not see tenant-owned OS") + }) + + t.Run("tenant admin sees own and provider-owned OS at accessible site", func(t *testing.T) { + oss := execGetAll(t, env.tnUser) + assert.GreaterOrEqual(t, len(oss), 2) + ids := make([]string, len(oss)) + for i, o := range oss { + ids[i] = o.ID + } + assert.Contains(t, ids, provOS.ID.String(), "tenant should see provider OS at accessible site") + assert.Contains(t, ids, tnOS.ID.String()) + }) + + t.Run("dual-role user sees both provider-owned and tenant-owned OSes", func(t *testing.T) { + oss := execGetAll(t, env.dualUser) + assert.GreaterOrEqual(t, len(oss), 2) + }) +} + +// ─── GetByID: cross-ownership visibility ───────────────────────────────────── + +// TestOperatingSystemHandler_GetByID_CrossOwnership verifies role-based +// visibility for individual OS fetches: +// - Provider Admin can fetch provider-owned OS, but NOT tenant-owned. +// - Tenant Admin can fetch own OS + provider-owned OS at accessible sites. +func TestOperatingSystemHandler_GetByID_CrossOwnership(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // Provider-owned OS with site association (visible to tenant via site). + provOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "prov-single", + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe"), + IpxeOsScope: cutil.GetPtr(cdbm.OperatingSystemScopeLocal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(env.dbSession) + _, err = ossaDAO.Create(ctx, nil, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: provOS.ID, + SiteID: env.site.ID, + Status: cdbm.OperatingSystemSiteAssociationStatusSynced, + }) + require.NoError(t, err) + + tnOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tenant-single", + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe"), + IpxeOsScope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + + execGetByID := func(t *testing.T, user *cdbm.User, osID string) *httptest.ResponseRecorder { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName", "id") + ec.SetParamValues(env.sharedOrg, osID) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := GetOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + } + require.NoError(t, h.Handle(ec)) + return rec + } + + t.Run("provider admin gets provider-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.ipUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin gets provider-owned OS at accessible site", func(t *testing.T) { + rec := execGetByID(t, env.tnUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("provider admin cannot get tenant-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.ipUser, tnOS.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin gets tenant-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.tnUser, tnOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user gets provider-owned OS", func(t *testing.T) { + rec := execGetByID(t, env.dualUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) +} + +// ─── Update: role-based ownership enforcement ───────────────────────────────── + +// TestOperatingSystemHandler_Update_OwnershipEnforcement exercises the Update +// handler's role-based mutation rules: +// +// - Provider Admin can update only provider-owned OSes → 200 / 403 +// - Tenant Admin can update only tenant-owned OSes → 200 / 403 +// - Dual-role user is permitted if either role authorizes the action +func TestOperatingSystemHandler_Update_OwnershipEnforcement(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // Provider-owned iPXE OS (no site associations → no Temporal calls needed). + provOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "prov-update-target", + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + + // Tenant-owned iPXE OS (no site associations). + tnOS, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tn-update-target", + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + + newScript := "updated-ipxe-script" + updateBody := model.APIOperatingSystemUpdateRequest{ + IpxeScript: &newScript, + } + rawUpdate, err := json.Marshal(updateBody) + require.NoError(t, err) + + execUpdate := func(t *testing.T, user *cdbm.User, osID string) *httptest.ResponseRecorder { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(string(rawUpdate))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName", "id") + ec.SetParamValues(env.sharedOrg, osID) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := UpdateOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + scp: env.scp, + } + require.NoError(t, h.Handle(ec)) + return rec + } + + t.Run("provider admin updates provider-owned OS → 200", func(t *testing.T) { + rec := execUpdate(t, env.ipUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("provider admin updates tenant-owned OS → 403", func(t *testing.T) { + rec := execUpdate(t, env.ipUser, tnOS.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin updates tenant-owned OS → 200", func(t *testing.T) { + rec := execUpdate(t, env.tnUser, tnOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin updates provider-owned OS → 403", func(t *testing.T) { + rec := execUpdate(t, env.tnUser, provOS.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user updates provider-owned OS → 200 (provider role authorizes)", func(t *testing.T) { + rec := execUpdate(t, env.dualUser, provOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user updates tenant-owned OS → 200 (tenant role authorizes)", func(t *testing.T) { + rec := execUpdate(t, env.dualUser, tnOS.ID.String()) + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) +} + +// ─── Delete: role-based ownership enforcement ───────────────────────────────── + +// TestOperatingSystemHandler_Delete_OwnershipEnforcement exercises the Delete +// handler's role-based mutation rules. iPXE OSes without site associations +// are used so no Temporal workflow is invoked. +// +// - Provider Admin deletes provider-owned OS → 202 +// - Provider Admin deletes tenant-owned OS → 403 +// - Tenant Admin deletes tenant-owned OS → 202 +// - Tenant Admin deletes provider-owned OS → 403 +// - Dual-role user deletes provider-owned OS → 202 (provider role authorizes) +// - Dual-role user deletes tenant-owned OS → 202 (tenant role authorizes) +func TestOperatingSystemHandler_Delete_OwnershipEnforcement(t *testing.T) { + env := newOwnershipTestEnv(t) + ctx := context.Background() + + osDAO := cdbm.NewOperatingSystemDAO(env.dbSession) + + // helper: create a fresh provider-owned iPXE OS. + newProvOS := func(suffix string) *cdbm.OperatingSystem { + os, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "prov-del-" + suffix, + Org: env.sharedOrg, + TenantID: nil, + InfrastructureProviderID: &env.ip.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.ipUser.ID, + }) + require.NoError(t, err) + return os + } + + // helper: create a fresh tenant-owned iPXE OS. + newTnOS := func(suffix string) *cdbm.OperatingSystem { + os, err := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + Name: "tn-del-" + suffix, + Org: env.sharedOrg, + TenantID: &env.tenant.ID, + OsType: cdbm.OperatingSystemTypeIPXE, + IpxeScript: cutil.GetPtr("#!ipxe\nboot"), + Status: cdbm.OperatingSystemStatusReady, + CreatedBy: env.tnUser.ID, + }) + require.NoError(t, err) + return os + } + + execDelete := func(t *testing.T, user *cdbm.User, osID string) *httptest.ResponseRecorder { + t.Helper() + tracer, ctx := otelTraceCtx(t) + + eh := echo.New() + req := httptest.NewRequest(http.MethodDelete, "/", nil) + rec := httptest.NewRecorder() + + ec := eh.NewContext(req, rec) + ec.SetParamNames("orgName", "id") + ec.SetParamValues(env.sharedOrg, osID) + ec.Set("user", user) + ec.SetRequest(ec.Request().WithContext( + context.WithValue(ctx, otelecho.TracerKey, tracer), + )) + + cfg := common.GetTestConfig() + h := DeleteOperatingSystemHandler{ + dbSession: env.dbSession, + tc: env.tempClient, + cfg: cfg, + scp: env.scp, + } + require.NoError(t, h.Handle(ec)) + return rec + } + + t.Run("provider admin deletes provider-owned OS → 202", func(t *testing.T) { + os := newProvOS(uuid.NewString()) + rec := execDelete(t, env.ipUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) + + t.Run("provider admin deletes tenant-owned OS → 403", func(t *testing.T) { + os := newTnOS(uuid.NewString()) + rec := execDelete(t, env.ipUser, os.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin deletes tenant-owned OS → 202", func(t *testing.T) { + os := newTnOS(uuid.NewString()) + rec := execDelete(t, env.tnUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) + + t.Run("tenant admin deletes provider-owned OS → 403", func(t *testing.T) { + os := newProvOS(uuid.NewString()) + rec := execDelete(t, env.tnUser, os.ID.String()) + assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user deletes provider-owned OS → 202 (provider role authorizes)", func(t *testing.T) { + os := newProvOS(uuid.NewString()) + rec := execDelete(t, env.dualUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) + + t.Run("dual-role user deletes tenant-owned OS → 202 (tenant role authorizes)", func(t *testing.T) { + os := newTnOS(uuid.NewString()) + rec := execDelete(t, env.dualUser, os.ID.String()) + assert.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + }) +} + +// ─── Create: scope and site-association behaviour ───────────────────────────── + +// TestOperatingSystemHandler_Create_ScopeAndSiteAssociation verifies that: +// - Templated iPXE with Global scope auto-associates with all registered provider sites +// - Templated iPXE with Limited scope associates only with specified sites +// - Raw iPXE auto-sets scope to Global and auto-associates with tenant-accessible sites +// - Response includes correct scope, type, and site associations +func TestOperatingSystemHandler_Create_ScopeAndSiteAssociation(t *testing.T) { + env := newOwnershipTestEnv(t) + + scopeGlobal := cdbm.OperatingSystemScopeGlobal + scopeLimited := cdbm.OperatingSystemScopeLimited + templateName := "test-template" + + tests := []struct { + name string + user *cdbm.User + body model.APIOperatingSystemCreateRequest + wantStatus int + wantType string + wantScope *string + wantSiteCount int + wantProviderOwned bool + }{ + { + name: "provider admin template iPXE global scope → auto-associates with provider sites", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "prov-global-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeTemplatedIPXE, + wantScope: &scopeGlobal, + wantSiteCount: 1, // one registered site in env + wantProviderOwned: true, + }, + { + name: "provider admin template iPXE limited scope → associates with specified sites", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "prov-limited-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeLimited, + SiteIDs: []string{env.site.ID.String()}, + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeTemplatedIPXE, + wantScope: &scopeLimited, + wantSiteCount: 1, + wantProviderOwned: true, + }, + { + name: "tenant admin raw iPXE → scope auto-set to Global, associates with tenant sites", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-raw-ipxe-" + uuid.NewString(), + IpxeScript: cutil.GetPtr("#!ipxe\nboot"), + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeIPXE, + wantScope: &scopeGlobal, + wantSiteCount: 1, // tenant has access to env.site + }, + { + name: "tenant admin template iPXE global scope → tenant-owned, associates with tenant sites", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tn-tmpl-global-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeGlobal, + }, + wantStatus: http.StatusCreated, + wantType: cdbm.OperatingSystemTypeTemplatedIPXE, + wantScope: &scopeGlobal, + wantSiteCount: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := env.execCreateOS(t, tc.user, tc.body) + require.Equal(t, tc.wantStatus, rec.Code, "response body: %s", rec.Body.String()) + + var rsp model.APIOperatingSystem + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rsp)) + + require.NotNil(t, rsp.Type, "type must be set") + assert.Equal(t, tc.wantType, *rsp.Type) + + if tc.wantScope != nil { + require.NotNil(t, rsp.Scope, "scope must be set in response") + assert.Equal(t, *tc.wantScope, *rsp.Scope) + } + + assert.Len(t, rsp.SiteAssociations, tc.wantSiteCount, + "expected %d site associations, got %d", tc.wantSiteCount, len(rsp.SiteAssociations)) + + if tc.wantProviderOwned { + assert.NotNil(t, rsp.InfrastructureProviderID) + assert.Nil(t, rsp.TenantID) + } else { + assert.Nil(t, rsp.InfrastructureProviderID) + assert.NotNil(t, rsp.TenantID) + } + + assert.Equal(t, cdbm.OperatingSystemStatusSyncing, rsp.Status) + assert.True(t, len(rsp.StatusHistory) >= 1, "expected at least one status history entry") + }) + } +} + +// ─── Create: validation error paths for scope ───────────────────────────────── + +// TestOperatingSystemHandler_Create_ScopeValidationErrors verifies API-level +// rejection of invalid scope combinations that the model validation covers. +func TestOperatingSystemHandler_Create_ScopeValidationErrors(t *testing.T) { + env := newOwnershipTestEnv(t) + + scopeLocal := cdbm.OperatingSystemScopeLocal + scopeLimited := cdbm.OperatingSystemScopeLimited + templateName := "test-template" + + tests := []struct { + name string + user *cdbm.User + body model.APIOperatingSystemCreateRequest + wantStatus int + }{ + { + name: "template iPXE with Local scope → 400", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tmpl-local-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeLocal, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "template iPXE with Limited scope but no siteIds → 400", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tmpl-limited-nosites-" + uuid.NewString(), + IpxeTemplateId: &templateName, + Scope: &scopeLimited, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "template iPXE missing scope entirely → 400", + user: env.ipUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "tmpl-noscope-" + uuid.NewString(), + IpxeTemplateId: &templateName, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "raw iPXE with scope specified → 400", + user: env.tnUser, + body: model.APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-scope-" + uuid.NewString(), + IpxeScript: cutil.GetPtr("#!ipxe\nboot"), + Scope: &scopeLimited, + }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := env.execCreateOS(t, tc.user, tc.body) + assert.Equal(t, tc.wantStatus, rec.Code, "response body: %s", rec.Body.String()) + }) + } +} diff --git a/rest-api/api/pkg/api/handler/operatingsystem_test.go b/rest-api/api/pkg/api/handler/operatingsystem_test.go index 5897688224..2934a199a8 100644 --- a/rest-api/api/pkg/api/handler/operatingsystem_test.go +++ b/rest-api/api/pkg/api/handler/operatingsystem_test.go @@ -4,6 +4,7 @@ package handler import ( + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" "context" "encoding/json" "errors" @@ -18,9 +19,7 @@ import ( "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" cdmu "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model/util" 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/otelecho" - cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/ipam" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" @@ -47,14 +46,14 @@ func TestOperatingSystemHandler_Create(t *testing.T) { ipOrg1 := "test-ip-org-1" ipOrg2 := "test-ip-org-2" ipOrg3 := "test-ip-org-3" - ipRoles := []string{authz.ProviderAdminRole} + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} testMachineBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg1, ipOrg2, ipOrg3}, ipRoles) tnOrg1 := "test-tn-org-1" tnOrg2 := "test-tn-org-2" tnOrg3 := "test-tn-org-3" - tnRoles := []string{authz.TenantAdminRole} + tnRoles := []string{"FORGE_TENANT_ADMIN"} ip := testMachineBuildInfrastructureProvider(t, dbSession, ipOrg1, "infra-provider-1") assert.NotNil(t, ip) @@ -85,7 +84,7 @@ func TestOperatingSystemHandler_Create(t *testing.T) { cfg := common.GetTestConfig() tempClient := &tmocks.Client{} - osObj := model.APIOperatingSystemCreateRequest{Name: "test-operating-system-1", Description: cutil.GetPtr("test"), InfrastructureProviderID: nil, TenantID: cutil.GetPtr(tenant1.ID.String()), IpxeScript: cutil.GetPtr("ipxe"), ImageDisk: cutil.GetPtr("/dev/sda"), UserData: cutil.GetPtr(cdmu.TestCommonCloudInit), IsCloudInit: true, AllowOverride: false} + osObj := model.APIOperatingSystemCreateRequest{Name: "test-operating-system-1", Description: cutil.GetPtr("test"), InfrastructureProviderID: nil, TenantID: cutil.GetPtr(tenant1.ID.String()), IpxeScript: cutil.GetPtr("ipxe"), UserData: cutil.GetPtr(cdmu.TestCommonCloudInit), IsCloudInit: true, AllowOverride: false} okBody, err := json.Marshal(osObj) assert.Nil(t, err) @@ -142,7 +141,7 @@ func TestOperatingSystemHandler_Create(t *testing.T) { wrun.Mock.On("Get", mock.Anything, mock.Anything).Return(nil) tempClient.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), - mock.AnythingOfType("func(internal.Context, uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("uuid.UUID"), + mock.AnythingOfType("func(internal.Context, []uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("[]uuid.UUID"), mock.AnythingOfType("uuid.UUID")).Return(wrun, nil) tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), @@ -284,7 +283,7 @@ func TestOperatingSystemHandler_Create(t *testing.T) { user: tnu, expectedErr: false, expectedStatus: http.StatusCreated, - expectedOperatingSystemStatus: cdbm.OperatingSystemStatusReady, + expectedOperatingSystemStatus: cdbm.OperatingSystemStatusSyncing, expectedStatusHistoryCount: 1, verifyChildSpanner: true, }, @@ -387,7 +386,7 @@ func TestOperatingSystemHandler_GetAll(t *testing.T) { ipOrg1 := "test-ip-org-1" ipOrg2 := "test-ip-org-2" ipOrg3 := "test-ip-org-3" - ipRoles := []string{authz.ProviderAdminRole} + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} ip := testMachineBuildInfrastructureProvider(t, dbSession, ipOrg1, "infra-provider-1") assert.NotNil(t, ip) @@ -401,7 +400,7 @@ func TestOperatingSystemHandler_GetAll(t *testing.T) { tnOrg2 := "test-tn-org-2" tnOrg3 := "test-tn-org-3" tnOrg4 := "test-tn-org-4" - tnRoles := []string{authz.TenantAdminRole} + tnRoles := []string{"FORGE_TENANT_ADMIN"} tnu := testMachineBuildUser(t, dbSession, uuid.NewString(), []string{tnOrg1, tnOrg2, tnOrg3, tnOrg4}, tnRoles) @@ -861,7 +860,7 @@ func TestOperatingSystemHandler_GetByID(t *testing.T) { tnOrg1 := "test-tn-org-1" tnOrg2 := "test-tn-org-2" - orgRoles := []string{authz.TenantAdminRole} + orgRoles := []string{"FORGE_TENANT_ADMIN"} user := testMachineBuildUser(t, dbSession, uuid.New().String(), []string{ipOrg1, ipOrg2, ipOrg3, tnOrg1, tnOrg2}, orgRoles) @@ -1139,7 +1138,7 @@ func TestOperatingSystemHandler_Update(t *testing.T) { ipOrg3 := "test-ip-org-3" tnOrg1 := "test-tn-org-1" tnOrg2 := "test-tn-org-2" - orgRoles := []string{authz.TenantAdminRole} + orgRoles := []string{"FORGE_TENANT_ADMIN"} user := testMachineBuildUser(t, dbSession, uuid.New().String(), []string{ipOrg1, ipOrg2, ipOrg3, tnOrg1, tnOrg2}, orgRoles) ip := testMachineBuildInfrastructureProvider(t, dbSession, ipOrg1, "infra-provider-1") @@ -1538,7 +1537,7 @@ func TestOperatingSystemHandler_Update(t *testing.T) { wrun.Mock.On("Get", mock.Anything, mock.Anything).Return(nil) tempClient.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), - mock.AnythingOfType("func(internal.Context, uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("uuid.UUID"), + mock.AnythingOfType("func(internal.Context, []uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("[]uuid.UUID"), mock.AnythingOfType("uuid.UUID")).Return(wrun, nil) tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), @@ -1615,7 +1614,7 @@ func TestOperatingSystemHandler_Update(t *testing.T) { user: user, osID: os2.ID.String(), expectedErr: true, - expectedStatus: http.StatusBadRequest, + expectedStatus: http.StatusForbidden, }, { name: "error when req body doesnt bind", @@ -1683,32 +1682,22 @@ func TestOperatingSystemHandler_Update(t *testing.T) { verifyChildSpanner: true, }, { - name: "should succeed to deactivate active OS", + name: "should reject deactivate on Image OS", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyDeactivate), osID: os10.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqDeactivate.Name, - expectedDesc: updReqDeactivate.Description, - expectedIsActive: cutil.GetPtr(false), - expectedDeactivationNote: updReqDeactivate.DeactivationNote, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "should succeed to deactivate active OS without Deactivation Note", + name: "should reject deactivate on Image OS without Deactivation Note", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyDeactivateNoNote), osID: os11.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqDeactivateNoNote.Name, - expectedDesc: updReqDeactivateNoNote.Description, - expectedIsActive: cutil.GetPtr(false), - expectedDeactivationNote: updReqDeactivateNoNote.DeactivationNote, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { name: "should fail to change Deactivation Note for an active OS", @@ -1720,53 +1709,42 @@ func TestOperatingSystemHandler_Update(t *testing.T) { expectedStatus: http.StatusBadRequest, }, { - name: "should succeed to change Deactivation Note on deactivated OS", + name: "should reject change of Deactivation Note on deactivated Image OS", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyChangeNote), osID: os12.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqChangeNote.Name, - expectedDesc: updReqChangeNote.Description, - expectedIsActive: cutil.GetPtr(false), - expectedDeactivationNote: updReqChangeNote.DeactivationNote, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "should succeed to activate deactivated OS (3/4)", + name: "should reject activate on deactivated Image OS", reqOrgName: ipOrg1, user: user, reqBody: string(okBodyActivate), osID: os13.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - - expectedName: updReqActivate.Name, - expectedDesc: updReqActivate.Description, - expectedIsActive: cutil.GetPtr(false), - expectedDeactivationNote: nil, + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "success when updated with required valid imageURL attribute", - reqOrgName: ipOrg1, - user: user, - reqBody: string(okBodyImageUrl), - reqUpdateModel: &updReqImageUrl, - osID: os5.ID.String(), - expectedErr: false, - expectedStatus: http.StatusOK, - expectedImageURL: cutil.GetPtr("http://newimagepath.iso"), + name: "should reject imageURL update on Image OS", + reqOrgName: ipOrg1, + user: user, + reqBody: string(okBodyImageUrl), + reqUpdateModel: &updReqImageUrl, + osID: os5.ID.String(), + expectedErr: true, + expectedStatus: http.StatusBadRequest, }, { - name: "error when updated with required valid imageURL attribute failed with context deadline error", + name: "should reject imageURL update on Image OS in second org", reqOrgName: ipOrg2, user: user, reqBody: string(okBodyImageUrl), reqUpdateModel: &updReqImageUrl, osID: os9.ID.String(), expectedErr: true, - expectedStatus: http.StatusInternalServerError, + expectedStatus: http.StatusBadRequest, }, } for _, tc := range tests { @@ -1880,7 +1858,7 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { ipOrg1 := "test-ip-org-1" ipOrg2 := "test-ip-org-2" ipOrg3 := "test-ip-org-3" - ipRoles := []string{authz.ProviderAdminRole} + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} ipu := testMachineBuildUser(t, dbSession, uuid.New().String(), []string{ipOrg1, ipOrg2, ipOrg3}, ipRoles) @@ -1888,7 +1866,7 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { tnOrg2 := "test-tn-org-2" tnOrg3 := "test-tn-org-3" tnOrg4 := "test-tn-org-4" - tnRoles := []string{authz.TenantAdminRole} + tnRoles := []string{"FORGE_TENANT_ADMIN"} tnu := testMachineBuildUser(t, dbSession, uuid.New().String(), []string{tnOrg1, tnOrg2, tnOrg3, tnOrg4}, tnRoles) @@ -2071,7 +2049,7 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { wrun.Mock.On("Get", mock.Anything, mock.Anything).Return(nil) tempClient.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), - mock.AnythingOfType("func(internal.Context, uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("uuid.UUID"), + mock.AnythingOfType("func(internal.Context, []uuid.UUID, uuid.UUID) error"), mock.AnythingOfType("[]uuid.UUID"), mock.AnythingOfType("uuid.UUID")).Return(wrun, nil) tsc.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), @@ -2097,23 +2075,23 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { tscWithTimeout.Mock.On("TerminateWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) // - // NICo not-found mocking + // Carbide not-found mocking // - scpWithNICoNotFound := sc.NewClientPool(tcfg) - tscWithNICoNotFound := &tmocks.Client{} + scpWithCarbideNotFound := sc.NewClientPool(tcfg) + tscWithCarbideNotFound := &tmocks.Client{} - scpWithNICoNotFound.IDClientMap[site.ID.String()] = tscWithNICoNotFound - scpWithNICoNotFound.IDClientMap[site2.ID.String()] = tscWithNICoNotFound + scpWithCarbideNotFound.IDClientMap[site.ID.String()] = tscWithCarbideNotFound + scpWithCarbideNotFound.IDClientMap[site2.ID.String()] = tscWithCarbideNotFound - wrunWithNICoNotFound := &tmocks.WorkflowRun{} - wrunWithNICoNotFound.On("GetID").Return("workflow-WithNICoNotFound") + wrunWithCarbideNotFound := &tmocks.WorkflowRun{} + wrunWithCarbideNotFound.On("GetID").Return("workflow-WithCarbideNotFound") - wrunWithNICoNotFound.Mock.On("Get", mock.Anything, mock.Anything).Return(tp.NewNonRetryableApplicationError("NICo went bananas", swe.ErrTypeNICoObjectNotFound, errors.New("NICo went bananas"))) + wrunWithCarbideNotFound.Mock.On("Get", mock.Anything, mock.Anything).Return(tp.NewNonRetryableApplicationError("Carbide went bananas", swe.ErrTypeCarbideObjectNotFound, errors.New("Carbide went bananas"))) - tscWithNICoNotFound.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), - "DeleteOsImage", mock.Anything).Return(wrunWithNICoNotFound, nil) + tscWithCarbideNotFound.Mock.On("ExecuteWorkflow", mock.Anything, mock.AnythingOfType("internal.StartWorkflowOptions"), + "DeleteOsImage", mock.Anything).Return(wrunWithCarbideNotFound, nil) - tscWithNICoNotFound.Mock.On("TerminateWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + tscWithCarbideNotFound.Mock.On("TerminateWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) tests := []struct { name string @@ -2161,7 +2139,7 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { user: tnu, osID: os3.ID.String(), expectedErr: true, - expectedStatus: http.StatusBadRequest, + expectedStatus: http.StatusForbidden, }, { name: "error when instance present for os", @@ -2203,15 +2181,15 @@ func TestOperatingSystemHandler_Delete(t *testing.T) { tClient: tscWithTimeout, }, { - name: "nico not-found success", + name: "carbide not-found success", reqOrgName: tnOrg1, user: tnu, osID: os5.ID.String(), expectedErr: false, expectedStatus: http.StatusAccepted, verifyChildSpanner: true, - clientPool: scpWithNICoNotFound, - tClient: tscWithNICoNotFound, + clientPool: scpWithCarbideNotFound, + tClient: tscWithCarbideNotFound, }, } for _, tc := range tests { 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 900aed30eb..b1294ec902 100644 --- a/rest-api/api/pkg/api/handler/util/common/testing.go +++ b/rest-api/api/pkg/api/handler/util/common/testing.go @@ -167,6 +167,22 @@ func TestSetupSchema(t *testing.T, dbSession *cdb.Session) { // create DpuExtensionServiceDeployment table err = dbSession.DB.ResetModel(context.Background(), (*cdbm.DpuExtensionServiceDeployment)(nil)) assert.Nil(t, err) + // create IpxeTemplate table + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.IpxeTemplate)(nil)) + assert.Nil(t, err) + // add UNIQUE(name) on ipxe_template (applied by migration in production) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template DROP CONSTRAINT IF EXISTS ipxe_template_name_key") + assert.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template ADD CONSTRAINT ipxe_template_name_key UNIQUE (name)") + assert.Nil(t, err) + // create IpxeTemplateSiteAssociation table + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.IpxeTemplateSiteAssociation)(nil)) + assert.Nil(t, err) + // add UNIQUE(ipxe_template_id, site_id) on ITSA (applied by migration in production) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template_site_association DROP CONSTRAINT IF EXISTS ipxe_template_site_association_template_id_site_id_key") + assert.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template_site_association ADD CONSTRAINT ipxe_template_site_association_template_id_site_id_key UNIQUE (ipxe_template_id, site_id)") + assert.Nil(t, err) // setup ipam table ipamStorage := cipam.NewBunStorage(dbSession.DB, nil) 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..169c193e23 --- /dev/null +++ b/rest-api/api/pkg/api/model/ipxetemplate.go @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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"` + // Scope indicates the visibility of this template: "Internal" or "Public" + Scope string `json:"scope"` + // Created is the date and time the entity was created in this system + Created time.Time `json:"created"` + // Updated is the date and time the entity was last updated in this system + Updated time.Time `json:"updated"` +} + +// NewAPIIpxeTemplate accepts a DB layer IpxeTemplate object and returns an API layer object +func NewAPIIpxeTemplate(dbTemplate *cdbm.IpxeTemplate) *APIIpxeTemplate { + if dbTemplate == nil { + return nil + } + + requiredParams := dbTemplate.RequiredParams + if requiredParams == nil { + requiredParams = []string{} + } + + reservedParams := dbTemplate.ReservedParams + if reservedParams == nil { + reservedParams = []string{} + } + + requiredArtifacts := dbTemplate.RequiredArtifacts + if requiredArtifacts == nil { + requiredArtifacts = []string{} + } + + return &APIIpxeTemplate{ + ID: dbTemplate.ID.String(), + Name: dbTemplate.Name, + Template: dbTemplate.Template, + RequiredParams: requiredParams, + ReservedParams: reservedParams, + RequiredArtifacts: requiredArtifacts, + Scope: dbTemplate.Scope, + 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 37dece60e6..f79ee2925c 100644 --- a/rest-api/api/pkg/api/model/operatingsystem.go +++ b/rest-api/api/pkg/api/model/operatingsystem.go @@ -4,18 +4,20 @@ package model import ( + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" "errors" + "fmt" + "strings" "time" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model/util" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/go-ozzo/ozzo-validation/v4/is" + validationis "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/google/uuid" "gopkg.in/yaml.v3" - - "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model/util" - cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" - cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" - cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" ) const ( @@ -65,38 +67,60 @@ 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 name of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, …) for the iPXE OS definition + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and nico-core. + // Allowed values: "Global" (rest→core, all sites), "Limited" (rest→core, specific sites + // listed in siteIds). Required for Templated iPXE OS. For raw iPXE OS, only "Global" + // or unspecified is accepted; the handler always normalizes raw iPXE to "Global". + // Rejected for Image OS (validateImageOS). + Scope *string `json:"scope"` } // Validate ensure the values passed in request are acceptable -func (oscr *APIOperatingSystemCreateRequest) Validate() error { - var err error - err = validation.ValidateStruct(oscr, +func (oscr APIOperatingSystemCreateRequest) Validate() error { + err := validation.ValidateStruct(&oscr, validation.Field(&oscr.Name, validation.Required.Error(validationErrorStringLength), validation.By(util.ValidateNameCharacters), validation.Length(2, 256).Error(validationErrorStringLength)), + // TODO: InfrastructureProviderID as parameter is deprecated and will need to be removed by 2026-10-01. validation.Field(&oscr.InfrastructureProviderID, - // infrastructure provider id must be nil validation.Nil.Error(validationErrorInfrastructureProviderIDExpectNil)), + validation.Field(&oscr.TenantID, + validation.When(oscr.TenantID != nil, validationis.UUID.Error(validationErrorInvalidUUID))), ) if err != nil { return err } - // Make sure siteIds only required in case of image is OS based - if oscr.IpxeScript != nil && len(oscr.SiteIDs) > 0 { + if oscr.IpxeTemplateId != nil && strings.TrimSpace(*oscr.IpxeTemplateId) == "" { return validation.Errors{ - "siteIds": errors.New("cannot be specified for iPXE based Operating Systems"), + "ipxeTemplateId": errors.New("must not be empty"), } } - if oscr.IpxeScript != nil && oscr.ImageURL != nil { + if oscr.IpxeScript != nil && oscr.IpxeTemplateId != nil { return validation.Errors{ - "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), + "ipxeTemplateId": errors.New("ipxeScript and ipxeTemplateId are mutually exclusive"), } - } else if oscr.IpxeScript == nil && oscr.ImageURL == nil { + } + + osType := oscr.GetOperatingSystemType() + + if osType == cdbm.OperatingSystemTypeImage && 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"), + } + } + + if cdbm.IsIPXEType(osType) && oscr.ImageURL != nil { + return validation.Errors{ + "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), } } @@ -106,67 +130,175 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { } } - if oscr.ImageURL != nil { - err = validation.ValidateStruct(oscr, - validation.Field(&oscr.ImageURL, is.URL), - validation.Field(&oscr.ImageSHA, - validation.Required.Error(validationErrorValueRequired), - validation.When(oscr.ImageSHA != nil, validation.Match(util.ShaHashRegex).Error(errMsgInvalidImageSHA))), - validation.Field(&oscr.ImageAuthType, - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken), - validation.Required.Error("imageAuthType cannot be specified if imageAuthToken is not specified")), - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)), - validation.In(cdbm.OperatingSystemAuthTypeBasic, cdbm.OperatingSystemAuthTypeBearer).Error("imageAuthType must be Basic or Bearer")), - ), - validation.Field(&oscr.ImageAuthToken, - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthType), validation.Required.Error("imageAuthType must be specified when imageAuthToken is specified"))), - validation.Field(&oscr.ImageDisk, - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageDisk)), validation.Match(util.DiskImagePathRegex).Error(errMsgInvalidImageDiskPath))), - validation.Field(&oscr.RootFsID, - validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel), validation.Required.Error(errMsgExactlyOneRootFsField)), - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel)), validation.Empty.Error(errMsgExactlyOneRootFsField))), - validation.Field(&oscr.RootFsLabel, - validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsID), validation.Required.Error(errMsgExactlyOneRootFsField)), - validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsID)), validation.Empty.Error(errMsgExactlyOneRootFsField))), - ) - if len(oscr.SiteIDs) == 0 { - return validation.Errors{ - "siteIds": errors.New("must be specified for image based Operating Systems"), - } - } else if len(oscr.SiteIDs) > 1 { - return validation.Errors{ - "siteIds": errors.New("must specify a single Site ID. Creating Image based Operating System on more than one Site is not supported"), - } + switch osType { + case cdbm.OperatingSystemTypeIPXE: + return oscr.validateRawIpxeOS() + case cdbm.OperatingSystemTypeTemplatedIPXE: + return oscr.validateTemplatedIpxeOS() + case cdbm.OperatingSystemTypeImage: + return oscr.validateImageOS() + } + + return nil +} + +func (oscr APIOperatingSystemCreateRequest) validateImageOS() error { + if len(oscr.IpxeTemplateParameters) > 0 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for Image based Operating Systems"), + } + } + if len(oscr.IpxeTemplateArtifacts) > 0 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for Image based Operating Systems"), } - } else { - err = validation.ValidateStruct(oscr, - validation.Field(&oscr.SiteIDs, - validation.Nil.Error("siteIds cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageSHA, - validation.Nil.Error("imageSHA cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageAuthType, - validation.Nil.Error("imageAuthType cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageAuthToken, - validation.Nil.Error("imageAuthToken cannot be specified if imageURL is not specified")), - validation.Field(&oscr.ImageDisk, - validation.Nil.Error("imageDisk cannot be specified if imageURL is not specified")), - validation.Field(&oscr.RootFsID, - validation.Nil.Error("rootFsID cannot be specified if imageURL is not specified")), - validation.Field(&oscr.RootFsLabel, - validation.Nil.Error("rootFsLabel cannot be specified if imageURL is not specified")), - ) } - if oscr.IpxeScript != nil { - err = validation.ValidateStruct(oscr, - validation.Field(&oscr.IpxeScript, - validation.Required.Error(validationErrorValueRequired)), - validation.Field(&oscr.EnableBlockStorage, - validation.Empty.Error("enableBlockStorage must be false if ipxeScript is specified")), - ) + err := validation.ValidateStruct(&oscr, + validation.Field(&oscr.ImageURL, is.URL), + validation.Field(&oscr.ImageSHA, + validation.Required.Error(validationErrorValueRequired), + validation.When(oscr.ImageSHA != nil, validation.Match(util.ShaHashRegex).Error(errMsgInvalidImageSHA))), + validation.Field(&oscr.ImageAuthType, + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken), + validation.Required.Error("imageAuthType cannot be specified if imageAuthToken is not specified")), + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthType)), + validation.In(cdbm.OperatingSystemAuthTypeBasic, cdbm.OperatingSystemAuthTypeBearer).Error("imageAuthType must be Basic or Bearer")), + ), + validation.Field(&oscr.ImageAuthToken, + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageAuthToken)) && util.IsNilOrEmptyStrPtr(oscr.ImageAuthType), validation.Required.Error("imageAuthType must be specified when imageAuthToken is specified"))), + validation.Field(&oscr.ImageDisk, + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.ImageDisk)), validation.Match(util.DiskImagePathRegex).Error(errMsgInvalidImageDiskPath))), + validation.Field(&oscr.RootFsID, + validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel), validation.Required.Error(errMsgExactlyOneRootFsField)), + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsLabel)), validation.Empty.Error(errMsgExactlyOneRootFsField))), + validation.Field(&oscr.RootFsLabel, + validation.When(util.IsNilOrEmptyStrPtr(oscr.RootFsID), validation.Required.Error(errMsgExactlyOneRootFsField)), + validation.When(!(util.IsNilOrEmptyStrPtr(oscr.RootFsID)), validation.Empty.Error(errMsgExactlyOneRootFsField))), + ) + if err != nil { + return err } - return err + if len(oscr.SiteIDs) == 0 { + return validation.Errors{ + "siteIds": errors.New("must be specified for image based Operating Systems"), + } + } + if len(oscr.SiteIDs) > 1 { + return validation.Errors{ + "siteIds": errors.New("must specify a single Site ID. Creating Image based Operating System on more than one Site is not supported"), + } + } + + if oscr.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope can only be specified for Templated iPXE Operating Systems"), + } + } + + return nil +} + +// rejectImageFields validates that image-specific fields are not set. +func (oscr APIOperatingSystemCreateRequest) rejectImageFields() error { + return validation.ValidateStruct(&oscr, + validation.Field(&oscr.ImageSHA, + validation.Nil.Error("imageSHA cannot be specified if imageURL is not specified")), + validation.Field(&oscr.ImageAuthType, + validation.Nil.Error("imageAuthType cannot be specified if imageURL is not specified")), + validation.Field(&oscr.ImageAuthToken, + validation.Nil.Error("imageAuthToken cannot be specified if imageURL is not specified")), + validation.Field(&oscr.ImageDisk, + validation.Nil.Error("imageDisk cannot be specified if imageURL is not specified")), + validation.Field(&oscr.RootFsID, + validation.Nil.Error("rootFsID cannot be specified if imageURL is not specified")), + validation.Field(&oscr.RootFsLabel, + validation.Nil.Error("rootFsLabel cannot be specified if imageURL is not specified")), + ) +} + +func (oscr APIOperatingSystemCreateRequest) validateRawIpxeOS() error { + if err := oscr.rejectImageFields(); err != nil { + return err + } + + if err := validation.ValidateStruct(&oscr, + validation.Field(&oscr.IpxeScript, + validation.Required.Error(validationErrorValueRequired)), + ); err != nil { + return err + } + + if len(oscr.IpxeTemplateParameters) > 0 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for raw iPXE Operating Systems; use ipxeTemplateId for template-based OS"), + } + } + if len(oscr.IpxeTemplateArtifacts) > 0 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for raw iPXE Operating Systems; use ipxeTemplateId for template-based OS"), + } + } + + if oscr.Scope != nil && *oscr.Scope != cdbm.OperatingSystemScopeGlobal { + return validation.Errors{ + "scope": fmt.Errorf("scope must be %q or unspecified for raw iPXE Operating Systems", cdbm.OperatingSystemScopeGlobal), + } + } + + if len(oscr.SiteIDs) > 0 { + return validation.Errors{ + "siteIds": errors.New("siteIds cannot be specified for raw iPXE Operating Systems"), + } + } + + return nil +} + +func (oscr APIOperatingSystemCreateRequest) validateTemplatedIpxeOS() error { + if err := oscr.rejectImageFields(); err != nil { + return err + } + + if oscr.Scope == nil { + return validation.Errors{ + "scope": errors.New("scope is required for Templated iPXE Operating Systems"), + } + } + + switch *oscr.Scope { + case cdbm.OperatingSystemScopeGlobal, cdbm.OperatingSystemScopeLimited: + // valid + case cdbm.OperatingSystemScopeLocal: + return validation.Errors{ + "scope": errors.New("scope 'Local' cannot be specified at creation; Local Operating Systems are created in nico-core"), + } + default: + return validation.Errors{ + "scope": errors.New("scope must be one of 'Global' or 'Limited'"), + } + } + + if len(oscr.SiteIDs) > 0 && *oscr.Scope != cdbm.OperatingSystemScopeLimited { + return validation.Errors{ + "siteIds": errors.New("siteIds can only be specified for Templated iPXE Operating Systems with scope 'Limited'"), + } + } + if *oscr.Scope == cdbm.OperatingSystemScopeLimited && len(oscr.SiteIDs) == 0 { + return validation.Errors{ + "siteIds": errors.New("at least one siteId must be specified when scope is 'Limited'"), + } + } + + if err := validateIpxeTemplateParameters(oscr.IpxeTemplateParameters); err != nil { + return err + } + if err := validateIpxeTemplateArtifacts(oscr.IpxeTemplateArtifacts); err != nil { + return err + } + + return nil } func (oscr *APIOperatingSystemCreateRequest) ValidateAndSetUserData(phonehomeUrl string) error { @@ -226,25 +358,6 @@ func (oscr *APIOperatingSystemCreateRequest) ValidateAndSetUserData(phonehomeUrl return nil } -// ToProto builds the workflow request that asks a Site to create the -// OS image for this API request. `os` is the just-persisted DB record; -// its `ToImageAttributesProto(tenantOrg)` is the source of every wire -// field because the handler has already merged the request fields into -// the entity via the DAO before this method runs. `tenantOrg` is a -// side input — it lives on the request's resolved Tenant rather than -// on the entity, and the handler passes it through. -// -// The method trusts that the request has already been Validated (and -// that ValidateAndSetUserData has run) and that the handler has -// performed the cross-context checks Validate cannot see — most -// importantly that the OS is image-typed, since -// `ToImageAttributesProto` dereferences `ImageURL` and `ImageSHA`. -// For iPXE-typed records there is no Site-side image workflow, so -// this method should not be called. -func (oscr *APIOperatingSystemCreateRequest) ToProto(os *cdbm.OperatingSystem, tenantOrg string) *cwssaws.OsImageAttributes { - return os.ToImageAttributesProto(tenantOrg) -} - // APIOperatingSystemUpdateRequest is the data structure to capture user request to update an OperatingSystem type APIOperatingSystemUpdateRequest struct { // Name is the name of the OperatingSystem @@ -279,11 +392,19 @@ type APIOperatingSystemUpdateRequest struct { IsActive *bool `json:"isActive"` // DeactivationNote is the deactivation note if any DeactivationNote *string `json:"deactivationNote"` + // IpxeTemplateId is the name of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters *[]cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, …) for the iPXE OS definition + IpxeTemplateArtifacts *[]cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope is immutable after creation. If provided, the request is rejected. + Scope *string `json:"scope"` } // Validate ensure the values passed in request are acceptable -func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.OperatingSystem) error { - err := validation.ValidateStruct(osur, +func (osur APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.OperatingSystem) error { + err := validation.ValidateStruct(&osur, validation.Field(&osur.Name, validation.When(osur.Name != nil, validation.Required.Error(validationErrorStringLength)), validation.When(osur.Name != nil, validation.By(util.ValidateNameCharacters)), @@ -314,21 +435,45 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating } } - if osur.IpxeScript != nil && osur.ImageURL != nil { + 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.IpxeScript != nil || osur.IpxeTemplateId != nil) && osur.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), } } - // verify if os created with ipxe script, if yes reject the update if imageURL provided - if existingOS.Type == cdbm.OperatingSystemTypeIPXE && osur.ImageURL != nil { + // Reject cross-type field assignments (iPXE → Templated iPXE → Image). + if cdbm.IsIPXEType(existingOS.Type) && osur.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("unable to set image URL for iPXE based Operating System"), } + } else if existingOS.Type == cdbm.OperatingSystemTypeIPXE && osur.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("unable to set iPXE template for raw iPXE Operating System"), + } + } else if existingOS.Type == cdbm.OperatingSystemTypeTemplatedIPXE && osur.IpxeScript != nil { + return validation.Errors{ + "ipxeScript": errors.New("unable to set iPXE script for templated iPXE Operating System"), + } } else if existingOS.Type == cdbm.OperatingSystemTypeImage && osur.IpxeScript != nil { return validation.Errors{ "ipxeScript": errors.New("unable to set iPXE script for image based Operating System"), } + } else if existingOS.Type == cdbm.OperatingSystemTypeImage && osur.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("unable to set iPXE template for image based Operating System"), + } } isImageBased := existingOS.Type == cdbm.OperatingSystemTypeImage @@ -352,7 +497,7 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating } if osur.ImageURL != nil { - err = validation.ValidateStruct(osur, + err = validation.ValidateStruct(&osur, validation.Field(&osur.ImageURL, is.URL), validation.Field(&osur.ImageSHA, validation.Required.Error(validationErrorValueRequired), @@ -372,7 +517,7 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating validation.When(!(util.IsNilOrEmptyStrPtr(osur.RootFsID)), validation.Empty.Error(errMsgOnlyOneRootFsField))), ) } else { - err = validation.ValidateStruct(osur, + err = validation.ValidateStruct(&osur, validation.Field(&osur.ImageSHA, validation.Nil.Error("imageSHA cannot be specified if imageURL is not specified")), validation.Field(&osur.ImageAuthType, @@ -389,12 +534,58 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating } if osur.IpxeScript != nil { - err = validation.ValidateStruct(osur, + err = validation.ValidateStruct(&osur, validation.Field(&osur.IpxeScript, validation.Required.Error(validationErrorValueRequired)), ) } - return err + + if osur.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope cannot be changed after creation"), + } + } + + if err != nil { + return err + } + + isImageBased2 := existingOS.Type == cdbm.OperatingSystemTypeImage + isRawIpxe := existingOS.Type == cdbm.OperatingSystemTypeIPXE + + if osur.IpxeTemplateParameters != nil { + if isImageBased2 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for Image based Operating Systems"), + } + } + if isRawIpxe { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("cannot be specified for raw iPXE Operating Systems"), + } + } + if verr := validateIpxeTemplateParameters(*osur.IpxeTemplateParameters); verr != nil { + return verr + } + } + + if osur.IpxeTemplateArtifacts != nil { + if isImageBased2 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for Image based Operating Systems"), + } + } + if isRawIpxe { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("cannot be specified for raw iPXE Operating Systems"), + } + } + if verr := validateIpxeTemplateArtifacts(*osur.IpxeTemplateArtifacts); verr != nil { + return verr + } + } + + return nil } func (osur *APIOperatingSystemUpdateRequest) ValidateAndSetUserData(phonehomeUrl string, existingOS *cdbm.OperatingSystem) error { @@ -506,29 +697,6 @@ func (osur *APIOperatingSystemUpdateRequest) ValidateAndSetUserData(phonehomeUrl return nil } -// ToProto builds the workflow request that asks a Site to update the -// OS image for this API request. `uos` is the post-update DB record; -// its `ToImageAttributesProto(tenantOrg)` is the source of every wire -// field, so unchanged fields stay populated and updated fields reflect -// the just-persisted state. `tenantOrg` is a side input — it lives on -// the request's resolved Tenant rather than on the entity, and the -// handler passes it through. -// -// The same `OsImageAttributes` proto is used for both create and -// update workflows on the Site side, so this method delegates to the -// entity-level method rather than building a distinct wire shape. The -// request-level method exists so call sites stay uniform with the -// rest of the layered convention (handlers always invoke -// `apiRequest.ToProto(entity, ...)`). -// -// As with the create variant, the method trusts that the request has -// been Validated (Validate + ValidateAndSetUserData) and that the -// handler has confirmed the OS is image-typed before this is called; -// `ToImageAttributesProto` dereferences `ImageURL` and `ImageSHA`. -func (osur *APIOperatingSystemUpdateRequest) ToProto(uos *cdbm.OperatingSystem, tenantOrg string) *cwssaws.OsImageAttributes { - return uos.ToImageAttributesProto(tenantOrg) -} - // APIOperatingSystem is the data structure to capture API representation of an OS type APIOperatingSystem struct { // ID is the unique UUID v4 identifier for the Operating System @@ -561,8 +729,17 @@ type APIOperatingSystem struct { RootFsID *string `json:"rootFsId"` // RootFsLabel is root fs id for the Operating System image type RootFsLabel *string `json:"rootFsLabel"` - // IpxeScript is the ipxe ocript for the Operating System + // IpxeScript is the ipxe script for the Operating System IpxeScript *string `json:"ipxeScript"` + // IpxeTemplateId is the name of the iPXE template used by this Operating System + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters passed to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, …) for the iPXE OS definition + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and nico-core. + // One of "Local" (default), "Global", or "Limited". Only valid for Templated iPXE OSes. + Scope *string `json:"scope"` // PhoneHomeEnabled is an attribute which is specified by user if Operating System needs to be enabled for phone home or not PhoneHomeEnabled bool `json:"phoneHomeEnabled"` // UserData is the user data for the Operating System @@ -592,28 +769,32 @@ type APIOperatingSystem struct { // NewAPIOperatingSystem accepts a DB layer objects and returns an API layer object func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail, ossas []cdbm.OperatingSystemSiteAssociation, sttsmap map[uuid.UUID]*cdbm.TenantSite) *APIOperatingSystem { apiOperatingSystem := APIOperatingSystem{ - ID: dbOS.ID.String(), - Name: dbOS.Name, - Description: dbOS.Description, - Type: &dbOS.Type, - ImageURL: dbOS.ImageURL, - ImageSHA: dbOS.ImageSHA, - ImageAuthType: dbOS.ImageAuthType, - ImageAuthToken: dbOS.ImageAuthToken, - ImageDisk: dbOS.ImageDisk, - RootFsID: dbOS.RootFsID, - RootFsLabel: dbOS.RootFsLabel, - IpxeScript: dbOS.IpxeScript, - PhoneHomeEnabled: dbOS.PhoneHomeEnabled, - UserData: dbOS.UserData, - IsCloudInit: dbOS.IsCloudInit, - AllowOverride: dbOS.AllowOverride, - EnableBlockStorage: dbOS.EnableBlockStorage, - IsActive: dbOS.IsActive, - DeactivationNote: dbOS.DeactivationNote, - Status: dbOS.Status, - Created: dbOS.Created, - Updated: dbOS.Updated, + ID: dbOS.ID.String(), + Name: dbOS.Name, + Description: dbOS.Description, + Type: &dbOS.Type, + ImageURL: dbOS.ImageURL, + ImageSHA: dbOS.ImageSHA, + ImageAuthType: dbOS.ImageAuthType, + ImageAuthToken: dbOS.ImageAuthToken, + ImageDisk: dbOS.ImageDisk, + RootFsID: dbOS.RootFsID, + RootFsLabel: dbOS.RootFsLabel, + IpxeScript: dbOS.IpxeScript, + IpxeTemplateId: dbOS.IpxeTemplateId, + IpxeTemplateParameters: dbOS.IpxeTemplateParameters, + IpxeTemplateArtifacts: dbOS.IpxeTemplateArtifacts, + Scope: dbOS.IpxeOsScope, + PhoneHomeEnabled: dbOS.PhoneHomeEnabled, + UserData: dbOS.UserData, + IsCloudInit: dbOS.IsCloudInit, + AllowOverride: dbOS.AllowOverride, + EnableBlockStorage: dbOS.EnableBlockStorage, + IsActive: dbOS.IsActive, + DeactivationNote: dbOS.DeactivationNote, + Status: dbOS.Status, + Created: dbOS.Created, + Updated: dbOS.Updated, } if dbOS.InfrastructureProviderID != nil { apiOperatingSystem.InfrastructureProviderID = cutil.GetPtr(dbOS.InfrastructureProviderID.String()) @@ -640,6 +821,77 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail return &apiOperatingSystem } +// 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 +} + +// validCacheStrategies is the set of accepted CacheStrategy string values. +var validCacheStrategies = func() map[string]struct{} { + m := make(map[string]struct{}, len(cwssaws.IpxeTemplateArtifactCacheStrategy_value)) + for name := range cwssaws.IpxeTemplateArtifactCacheStrategy_value { + m[name] = struct{}{} + } + return m +}() + +func validateIpxeTemplateParameters(params []cdbm.OperatingSystemIpxeParameter) error { + for i, p := range params { + if strings.TrimSpace(p.Name) == "" { + return validation.Errors{ + "ipxeTemplateParameters": fmt.Errorf("entry %d: name is required", i), + } + } + } + return nil +} + +func validateIpxeTemplateArtifacts(artifacts []cdbm.OperatingSystemIpxeArtifact) error { + for i, a := range artifacts { + if strings.TrimSpace(a.Name) == "" { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d: name is required", i), + } + } + if strings.TrimSpace(a.URL) == "" { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): url is required", i, a.Name), + } + } + if _, ok := validCacheStrategies[a.CacheStrategy]; !ok { + return validation.Errors{ + "ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): cacheStrategy must be one of CACHE_AS_NEEDED, LOCAL_ONLY, CACHED_ONLY, REMOTE_ONLY", 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 +} + // APIOperatingSystemSummary is the data structure to capture API summary of an OperatingSystem type APIOperatingSystemSummary struct { // ID of the OperatingSystem diff --git a/rest-api/api/pkg/api/model/operatingsystem_test.go b/rest-api/api/pkg/api/model/operatingsystem_test.go index 9cf23771d1..e3949a99cf 100644 --- a/rest-api/api/pkg/api/model/operatingsystem_test.go +++ b/rest-api/api/pkg/api/model/operatingsystem_test.go @@ -4,21 +4,20 @@ package model import ( + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" "encoding/json" "fmt" "strings" "testing" "time" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model/util" cdmu "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model/util" - cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPIOperatingSystemCreateRequest_Validate(t *testing.T) { @@ -182,6 +181,277 @@ func TestAPIOperatingSystemCreateRequest_Validate(t *testing.T) { obj: APIOperatingSystemCreateRequest{Name: "abc", TenantID: cutil.GetPtr(uuid.New().String()), ImageURL: cutil.GetPtr("http://iso.net/iso"), SiteIDs: []string{uuid.NewString()}, ImageSHA: cutil.GetPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), RootFsID: cutil.GetPtr("666c2eee-193d-42db-a490-4c444342bd4e"), IsCloudInit: true, AllowOverride: false, ImageDisk: cutil.GetPtr(""), ImageAuthType: cutil.GetPtr(""), ImageAuthToken: cutil.GetPtr("")}, expectErr: false, }, + // ─── Templated iPXE OS validation ───────────────────────────────── + { + desc: "ok when valid templated iPXE with global scope", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-global", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "ip=dhcp"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://files.example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: false, + }, + { + desc: "ok when valid templated iPXE with limited scope and siteIds", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-limited", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited), + SiteIDs: []string{uuid.NewString()}, + }, + expectErr: false, + }, + { + desc: "error when templated iPXE missing scope", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-no-scope", + IpxeTemplateId: cutil.GetPtr("my-template"), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with scope Local", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-local", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLocal), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with invalid scope", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-scope", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr("InvalidScope"), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with limited scope but no siteIds", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-limited-no-sites", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE with global scope and siteIds", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-global-with-sites", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + SiteIDs: []string{uuid.NewString()}, + }, + expectErr: true, + }, + { + desc: "error when both ipxeScript and ipxeTemplateId specified", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-conflict", + IpxeScript: cutil.GetPtr("ipxe"), + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when ipxeTemplateId is empty string", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-empty-id", + IpxeTemplateId: cutil.GetPtr(""), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when ipxeTemplateId is whitespace only", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-ws-id", + IpxeTemplateId: cutil.GetPtr(" "), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when templated iPXE has image fields", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-image-fields", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + ImageSHA: cutil.GetPtr("abc123"), + }, + expectErr: true, + }, + { + desc: "error when template parameter has empty name", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-param", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "", Value: "val"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has empty name", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-art-name", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has empty URL", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-art-url", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has invalid cacheStrategy", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-bad-art-cache", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "INVALID_STRATEGY"}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has authType without authToken", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-auth-notoken", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthType: cutil.GetPtr("Bearer")}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has authToken without authType", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-token-notype", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthToken: cutil.GetPtr("secret")}, + }, + }, + expectErr: true, + }, + { + desc: "error when template artifact has invalid authType", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-bad-authtype", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthType: cutil.GetPtr("VAPID"), AuthToken: cutil.GetPtr("secret")}, + }, + }, + expectErr: true, + }, + { + desc: "ok when template artifact has valid auth pair", + obj: APIOperatingSystemCreateRequest{ + Name: "tmpl-os-art-valid-auth", + IpxeTemplateId: cutil.GetPtr("my-template"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED", AuthType: cutil.GetPtr("Bearer"), AuthToken: cutil.GetPtr("secret")}, + }, + }, + expectErr: false, + }, + { + desc: "raw iPXE with explicit Global scope is allowed", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-global-scope", + IpxeScript: cutil.GetPtr("ipxe-script"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: false, + }, + { + desc: "error when raw iPXE has non-Global scope specified", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-limited-scope", + IpxeScript: cutil.GetPtr("ipxe-script"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited), + }, + expectErr: true, + }, + { + desc: "error when raw iPXE has template parameters", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-params", + IpxeScript: cutil.GetPtr("ipxe-script"), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "k", Value: "v"}, + }, + }, + expectErr: true, + }, + { + desc: "error when raw iPXE has template artifacts", + obj: APIOperatingSystemCreateRequest{ + Name: "raw-ipxe-with-arts", + IpxeScript: cutil.GetPtr("ipxe-script"), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "k", URL: "http://example.com", CacheStrategy: "CACHE_AS_NEEDED"}, + }, + }, + expectErr: true, + }, + { + desc: "error when image OS has scope specified", + obj: APIOperatingSystemCreateRequest{ + Name: "image-os-with-scope", + ImageURL: cutil.GetPtr("http://iso.net/iso"), + SiteIDs: []string{uuid.NewString()}, + ImageSHA: cutil.GetPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), + RootFsID: cutil.GetPtr("666c2eee-193d-42db-a490-4c444342bd4e"), + Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + }, + expectErr: true, + }, + { + desc: "error when image OS has template parameters", + obj: APIOperatingSystemCreateRequest{ + Name: "image-os-with-params", + ImageURL: cutil.GetPtr("http://iso.net/iso"), + SiteIDs: []string{uuid.NewString()}, + ImageSHA: cutil.GetPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), + RootFsID: cutil.GetPtr("666c2eee-193d-42db-a490-4c444342bd4e"), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "k", Value: "v"}, + }, + }, + expectErr: true, + }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { @@ -215,6 +485,15 @@ func TestAPIOperatingSystemUpdateRequest_Validate(t *testing.T) { CreatedBy: uuid.New(), } + existingTemplatedIpxeOS := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "tmpl-os", + Status: cdbm.OperatingSystemStatusReady, + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + IsActive: true, + CreatedBy: uuid.New(), + } + existingImageBasedOSWithFSLabel := &cdbm.OperatingSystem{ ID: uuid.New(), Name: "abc", @@ -340,6 +619,89 @@ func TestAPIOperatingSystemUpdateRequest_Validate(t *testing.T) { obj: APIOperatingSystemUpdateRequest{Name: cutil.GetPtr("ab"), ImageURL: cutil.GetPtr("http://iso.net/iso"), ImageSHA: cutil.GetPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), RootFsID: cutil.GetPtr("666c2eee-193d-42db-a490-4c444342bd4e"), ImageDisk: cutil.GetPtr(""), ImageAuthType: cutil.GetPtr(""), ImageAuthToken: cutil.GetPtr("")}, expectErr: false, }, + // ─── Templated iPXE update validation ───────────────────────────── + { + desc: "error when scope is specified on update", + obj: APIOperatingSystemUpdateRequest{Name: cutil.GetPtr("updated-tmpl"), Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal)}, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateParameters specified for raw iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "k", Value: "v"}}}, + existingOS: existingIpxeBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateArtifacts specified for raw iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}}}, + existingOS: existingIpxeBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateParameters specified for image OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "k", Value: "v"}}}, + existingOS: existingImageBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateArtifacts specified for image OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}}}, + existingOS: existingImageBasedOS, + expectErr: true, + }, + { + desc: "ok when ipxeTemplateParameters updated for templated iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "kernel_params", Value: "ip=dhcp"}}}, + existingOS: existingTemplatedIpxeOS, + expectErr: false, + }, + { + desc: "ok when ipxeTemplateArtifacts updated for templated iPXE OS", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://example.com/vmlinuz", CacheStrategy: "CACHE_AS_NEEDED"}}}, + existingOS: existingTemplatedIpxeOS, + expectErr: false, + }, + { + desc: "error when ipxeTemplateId set on image OS update", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateId: cutil.GetPtr("my-template")}, + existingOS: existingImageBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeTemplateId set on raw iPXE OS update", + obj: APIOperatingSystemUpdateRequest{IpxeTemplateId: cutil.GetPtr("my-template")}, + existingOS: existingIpxeBasedOS, + expectErr: true, + }, + { + desc: "error when ipxeScript set on templated iPXE OS update", + obj: APIOperatingSystemUpdateRequest{IpxeScript: cutil.GetPtr("chain --autofree https://boot.example.com")}, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when ipxeScript and ipxeTemplateId both on update", + obj: APIOperatingSystemUpdateRequest{IpxeScript: cutil.GetPtr("script"), IpxeTemplateId: cutil.GetPtr("tmpl")}, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when template parameter has blank name on update", + obj: APIOperatingSystemUpdateRequest{ + IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: " ", Value: "v"}}, + }, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, + { + desc: "error when template artifact has invalid cacheStrategy on update", + obj: APIOperatingSystemUpdateRequest{ + IpxeTemplateArtifacts: &[]cdbm.OperatingSystemIpxeArtifact{{Name: "k", URL: "http://example.com/k", CacheStrategy: "BAD"}}, + }, + existingOS: existingTemplatedIpxeOS, + expectErr: true, + }, } for _, tc := range tests { @@ -890,78 +1252,3 @@ func TestAPIOperatingSystemNew(t *testing.T) { }) } } - -func TestAPIOperatingSystemCreateRequest_ToProto(t *testing.T) { - id := uuid.New() - url := "https://image" - sha := "deadbeef" - rootFsID := "fs-1" - os := &cdbm.OperatingSystem{ - ID: id, - Name: "ubuntu", - ImageURL: &url, - ImageSHA: &sha, - RootFsID: &rootFsID, - EnableBlockStorage: false, - } - t.Run("delegates to ToImageAttributesProto with tenantOrg", func(t *testing.T) { - req := APIOperatingSystemCreateRequest{} - got := req.ToProto(os, "org-1") - require.NotNil(t, got) - require.NotNil(t, got.Id) - assert.Equal(t, id.String(), got.Id.Value) - require.NotNil(t, got.Name) - assert.Equal(t, "ubuntu", *got.Name) - assert.Equal(t, "org-1", got.TenantOrganizationId) - assert.Equal(t, "https://image", got.SourceUrl) - assert.Equal(t, "deadbeef", got.Digest) - require.NotNil(t, got.RootfsId) - assert.Equal(t, "fs-1", *got.RootfsId) - }) - t.Run("uses ControllerOperatingSystemID when set", func(t *testing.T) { - ctrlID := uuid.New() - osWithCtrl := &cdbm.OperatingSystem{ - ID: id, - ControllerOperatingSystemID: &ctrlID, - Name: "ubuntu", - ImageURL: &url, - ImageSHA: &sha, - RootFsID: &rootFsID, - } - req := APIOperatingSystemCreateRequest{} - got := req.ToProto(osWithCtrl, "org-1") - require.NotNil(t, got) - require.NotNil(t, got.Id) - assert.Equal(t, ctrlID.String(), got.Id.Value) - }) -} - -func TestAPIOperatingSystemUpdateRequest_ToProto(t *testing.T) { - id := uuid.New() - url := "https://image-new" - sha := "cafebabe" - rootFsLabel := "lbl" - uos := &cdbm.OperatingSystem{ - ID: id, - Name: "ubuntu-22", - ImageURL: &url, - ImageSHA: &sha, - RootFsLabel: &rootFsLabel, - EnableBlockStorage: true, - } - t.Run("delegates to ToImageAttributesProto with tenantOrg", func(t *testing.T) { - req := APIOperatingSystemUpdateRequest{} - got := req.ToProto(uos, "org-2") - require.NotNil(t, got) - require.NotNil(t, got.Id) - assert.Equal(t, id.String(), got.Id.Value) - require.NotNil(t, got.Name) - assert.Equal(t, "ubuntu-22", *got.Name) - assert.Equal(t, "org-2", got.TenantOrganizationId) - assert.Equal(t, "https://image-new", got.SourceUrl) - assert.Equal(t, "cafebabe", got.Digest) - assert.True(t, got.CreateVolume) - require.NotNil(t, got.RootfsLabel) - assert.Equal(t, "lbl", *got.RootfsLabel) - }) -} diff --git a/rest-api/api/pkg/api/routes.go b/rest-api/api/pkg/api/routes.go index d912fc667a..b513c5ef77 100644 --- a/rest-api/api/pkg/api/routes.go +++ b/rest-api/api/pkg/api/routes.go @@ -875,6 +875,17 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa Method: http.MethodGet, Handler: apiHandler.NewGetSkuHandler(dbSession, tc, cfg), }, + // iPXE Template endpoints + { + 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), + }, // Task endpoints (Flow). /rack/task/* and /task/* share get/cancel // handlers; list operations are exposed under /rack/{id}/task and // /tray/{id}/task. diff --git a/rest-api/api/pkg/api/routes_test.go b/rest-api/api/pkg/api/routes_test.go index a57ab7a3e6..6d758a57d7 100644 --- a/rest-api/api/pkg/api/routes_test.go +++ b/rest-api/api/pkg/api/routes_test.go @@ -69,6 +69,7 @@ func TestNewAPIRoutes(t *testing.T) { "machine-validation": 11, "dpu-extension-service": 7, "sku": 2, + "ipxe-template": 2, "task": 2, "rule": 5, "rack": 13, diff --git a/rest-api/db/pkg/db/model/ipxetemplate.go b/rest-api/db/pkg/db/model/ipxetemplate.go new file mode 100644 index 0000000000..18541dab28 --- /dev/null +++ b/rest-api/db/pkg/db/model/ipxetemplate.go @@ -0,0 +1,294 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "database/sql" + "time" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" + stracer "github.com/NVIDIA/infra-controller/rest-api/db/pkg/tracer" + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +const ( + // IpxeTemplateRelationName is the relation name for the IpxeTemplate model + IpxeTemplateRelationName = "IpxeTemplate" + // IpxeTemplateOrderByCreated is the field name for ordering by created timestamp + IpxeTemplateOrderByCreated = "created" + // ipxeTemplateOrderByUpdated is the field name for ordering by updated timestamp + ipxeTemplateOrderByUpdated = "updated" + // IpxeTemplateOrderByName is the field name for ordering by name + IpxeTemplateOrderByName = "name" + // IpxeTemplateOrderByDefault is the default field for ordering + IpxeTemplateOrderByDefault = IpxeTemplateOrderByName + + // IpxeTemplateScopeInternal represents an internal-only template + IpxeTemplateScopeInternal = "Internal" + // IpxeTemplateScopePublic represents a public template + IpxeTemplateScopePublic = "Public" +) + +var ( + // IpxeTemplateOrderByFields is a list of valid order by fields for the IpxeTemplate model + IpxeTemplateOrderByFields = []string{IpxeTemplateOrderByCreated, ipxeTemplateOrderByUpdated, IpxeTemplateOrderByName} + // IpxeTemplateRelatedEntities is a list of valid relation by fields for the IpxeTemplate model. + // Per-site availability is tracked via IpxeTemplateSiteAssociation, not via a direct site relation. + IpxeTemplateRelatedEntities = map[string]bool{} +) + +// IpxeTemplate represents an iPXE script template propagated from nico-core. +// The primary key `ID` is the template UUID assigned by core and is consistent across +// REST and core. Per-site availability is tracked via IpxeTemplateSiteAssociation rows. +type IpxeTemplate struct { + bun.BaseModel `bun:"table:ipxe_template,alias:ipxet"` + + ID uuid.UUID `bun:"id,pk,type:uuid"` + Name string `bun:"name,notnull,unique"` + Template string `bun:"template,notnull,default:''"` + RequiredParams []string `bun:"required_params,type:text[],default:'{}'"` + ReservedParams []string `bun:"reserved_params,type:text[],default:'{}'"` + RequiredArtifacts []string `bun:"required_artifacts,type:text[],default:'{}'"` + Scope string `bun:"scope,notnull"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` +} + +// IpxeTemplateCreateInput are input parameters for the Create method. +// `ID` must be supplied (it is the stable template UUID from core). +type IpxeTemplateCreateInput struct { + ID uuid.UUID + Name string + Template string + RequiredParams []string + ReservedParams []string + RequiredArtifacts []string + Scope string +} + +// IpxeTemplateUpdateInput are input parameters for the Update method +type IpxeTemplateUpdateInput struct { + IpxeTemplateID uuid.UUID + Name string + Template string + RequiredParams []string + ReservedParams []string + RequiredArtifacts []string + Scope string +} + +// IpxeTemplateFilterInput are input parameters for the filter/GetAll method. +// Note: only `Public`-scoped templates are ever propagated into REST (see the +// workflow activity `UpdateIpxeTemplatesInDB`), so there is no scope filter. +// +// IDs filters on the template's primary key (which equals core's TemplateID). +// Names filters on the unique template name. +type IpxeTemplateFilterInput struct { + IDs []uuid.UUID + Names []string +} + +var _ bun.BeforeAppendModelHook = (*IpxeTemplate)(nil) + +// BeforeAppendModel is a hook called before the model is appended to the query +func (it *IpxeTemplate) BeforeAppendModel(ctx context.Context, query bun.Query) error { + switch query.(type) { + case *bun.InsertQuery: + it.Created = db.GetCurTime() + it.Updated = db.GetCurTime() + case *bun.UpdateQuery: + it.Updated = db.GetCurTime() + } + return nil +} + +// IpxeTemplateDAO is an interface for interacting with the IpxeTemplate model +type IpxeTemplateDAO interface { + // Create inserts a new iPXE template row + Create(ctx context.Context, tx *db.Tx, input IpxeTemplateCreateInput) (*IpxeTemplate, error) + // Update updates an existing iPXE template row + Update(ctx context.Context, tx *db.Tx, input IpxeTemplateUpdateInput) (*IpxeTemplate, error) + // Delete removes an iPXE template row by ID + Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error + // GetAll returns all rows matching the filter and page inputs + GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateFilterInput, page paginator.PageInput) ([]IpxeTemplate, int, error) + // Get returns the row for the specified ID (which is the core template UUID) + Get(ctx context.Context, tx *db.Tx, id uuid.UUID) (*IpxeTemplate, error) +} + +// IpxeTemplateSQLDAO is an implementation of the IpxeTemplateDAO interface +type IpxeTemplateSQLDAO struct { + dbSession *db.Session + IpxeTemplateDAO + tracerSpan *stracer.TracerSpan +} + +// Create inserts a new IpxeTemplate from the given parameters +func (itd IpxeTemplateSQLDAO) Create(ctx context.Context, tx *db.Tx, input IpxeTemplateCreateInput) (*IpxeTemplate, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Create") + if span != nil { + defer span.End() + } + + it := &IpxeTemplate{ + ID: input.ID, + Name: input.Name, + Template: input.Template, + RequiredParams: input.RequiredParams, + ReservedParams: input.ReservedParams, + RequiredArtifacts: input.RequiredArtifacts, + Scope: input.Scope, + } + + _, err := db.GetIDB(tx, itd.dbSession).NewInsert().Model(it).Exec(ctx) + if err != nil { + return nil, err + } + + return itd.Get(ctx, tx, it.ID) +} + +// Get returns an IpxeTemplate by ID +// Returns db.ErrDoesNotExist if the record is not found +func (itd IpxeTemplateSQLDAO) Get(ctx context.Context, tx *db.Tx, id uuid.UUID) (*IpxeTemplate, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Get") + if span != nil { + defer span.End() + itd.tracerSpan.SetAttribute(span, "id", id) + } + + it := &IpxeTemplate{} + + err := db.GetIDB(tx, itd.dbSession).NewSelect().Model(it).Where("ipxet.id = ?", id).Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return it, nil +} + +// setQueryWithFilter populates the lookup query based on the specified filter +func (itd IpxeTemplateSQLDAO) setQueryWithFilter(filter IpxeTemplateFilterInput, query *bun.SelectQuery, span *stracer.CurrentContextSpan) (*bun.SelectQuery, error) { + if len(filter.IDs) > 0 { + query = query.Where("ipxet.id IN (?)", bun.In(filter.IDs)) + if span != nil { + itd.tracerSpan.SetAttribute(span, "ids", filter.IDs) + } + } + + if len(filter.Names) > 0 { + query = query.Where("ipxet.name IN (?)", bun.In(filter.Names)) + if span != nil { + itd.tracerSpan.SetAttribute(span, "names", filter.Names) + } + } + + return query, nil +} + +// GetAll returns all IpxeTemplates with optional filters +// If orderBy is nil, records are ordered by IpxeTemplateOrderByDefault in ascending order +func (itd IpxeTemplateSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateFilterInput, page paginator.PageInput) ([]IpxeTemplate, int, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.GetAll") + if span != nil { + defer span.End() + } + + templates := []IpxeTemplate{} + + if filter.IDs != nil && len(filter.IDs) == 0 { + return templates, 0, nil + } + + query := db.GetIDB(tx, itd.dbSession).NewSelect().Model(&templates) + + query, err := itd.setQueryWithFilter(filter, query, span) + if err != nil { + return templates, 0, err + } + + if page.OrderBy == nil { + page.OrderBy = paginator.NewDefaultOrderBy(IpxeTemplateOrderByDefault) + } + + pager, err := paginator.NewPaginator(ctx, query, page.Offset, page.Limit, page.OrderBy, IpxeTemplateOrderByFields) + if err != nil { + return nil, 0, err + } + + err = pager.Query.Limit(pager.Limit).Offset(pager.Offset).Scan(ctx) + if err != nil { + return nil, 0, err + } + + return templates, pager.Total, nil +} + +// Update updates specified fields of an existing IpxeTemplate +func (itd IpxeTemplateSQLDAO) Update(ctx context.Context, tx *db.Tx, input IpxeTemplateUpdateInput) (*IpxeTemplate, error) { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Update") + if span != nil { + defer span.End() + itd.tracerSpan.SetAttribute(span, "id", input.IpxeTemplateID) + } + + it := &IpxeTemplate{ID: input.IpxeTemplateID} + updatedFields := []string{"name", "template", "required_params", "reserved_params", "required_artifacts", "scope", "updated"} + + it.Name = input.Name + it.Template = input.Template + it.RequiredParams = input.RequiredParams + it.ReservedParams = input.ReservedParams + it.RequiredArtifacts = input.RequiredArtifacts + it.Scope = input.Scope + + _, err := db.GetIDB(tx, itd.dbSession).NewUpdate().Model(it).Column(updatedFields...).Where("ipxet.id = ?", input.IpxeTemplateID).Exec(ctx) + if err != nil { + return nil, err + } + + return itd.Get(ctx, tx, it.ID) +} + +// Delete removes an IpxeTemplate by ID +func (itd IpxeTemplateSQLDAO) Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error { + ctx, span := itd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateDAO.Delete") + if span != nil { + defer span.End() + itd.tracerSpan.SetAttribute(span, "id", id) + } + + it := &IpxeTemplate{ID: id} + + _, err := db.GetIDB(tx, itd.dbSession).NewDelete().Model(it).Where("id = ?", id).Exec(ctx) + return err +} + +// NewIpxeTemplateDAO returns a new IpxeTemplateDAO +func NewIpxeTemplateDAO(dbSession *db.Session) IpxeTemplateDAO { + return &IpxeTemplateSQLDAO{ + dbSession: dbSession, + tracerSpan: stracer.NewTracerSpan(), + } +} diff --git a/rest-api/db/pkg/db/model/ipxetemplate_test.go b/rest-api/db/pkg/db/model/ipxetemplate_test.go new file mode 100644 index 0000000000..8c26c36365 --- /dev/null +++ b/rest-api/db/pkg/db/model/ipxetemplate_test.go @@ -0,0 +1,277 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "testing" + + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/util" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testIpxeTemplateSetupSchema(t *testing.T, dbSession *db.Session) { + ctx := context.Background() + require.Nil(t, dbSession.DB.ResetModel(ctx, (*IpxeTemplate)(nil))) + + // Add UNIQUE(name). This is applied by migration 20260306120000_ipxe_os_and_templates.go + // in production; tests use ResetModel so we add it here to match. + _, err := dbSession.DB.Exec("ALTER TABLE ipxe_template DROP CONSTRAINT IF EXISTS ipxe_template_name_key") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template ADD CONSTRAINT ipxe_template_name_key UNIQUE (name)") + require.Nil(t, err) +} + +func testIpxeTemplateInitDB(t *testing.T) *db.Session { + return util.GetTestDBSession(t, false) +} + +func testIpxeTemplateCreate(ctx context.Context, t *testing.T, dao IpxeTemplateDAO, name, scope string) *IpxeTemplate { + tmpl, err := dao.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: name, + RequiredParams: []string{"kernel_params"}, + ReservedParams: []string{"base_url", "console"}, + RequiredArtifacts: []string{"kernel", "initrd"}, + Scope: scope, + }) + require.NoError(t, err) + require.NotNil(t, tmpl) + return tmpl +} + +func TestIpxeTemplateSQLDAO_Create(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + + tests := []struct { + desc string + input IpxeTemplateCreateInput + expectError bool + }{ + { + desc: "create public template", + input: IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "kernel-initrd", + RequiredParams: []string{"kernel_params"}, + ReservedParams: []string{"base_url", "console"}, + RequiredArtifacts: []string{"kernel", "initrd"}, + Scope: IpxeTemplateScopePublic, + }, + }, + { + desc: "create internal template", + input: IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "discovery-scout-x86_64", + RequiredParams: []string{"mac", "cli_cmd", "machine_id", "server_uri"}, + ReservedParams: []string{"base_url"}, + RequiredArtifacts: []string{}, + Scope: IpxeTemplateScopeInternal, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + tmpl, err := dao.Create(ctx, nil, tc.input) + assert.Equal(t, tc.expectError, err != nil) + if !tc.expectError { + require.NotNil(t, tmpl) + assert.Equal(t, tc.input.ID, tmpl.ID) + assert.Equal(t, tc.input.Name, tmpl.Name) + assert.Equal(t, tc.input.Scope, tmpl.Scope) + assert.Equal(t, tc.input.RequiredParams, tmpl.RequiredParams) + assert.Equal(t, tc.input.ReservedParams, tmpl.ReservedParams) + assert.Equal(t, tc.input.RequiredArtifacts, tmpl.RequiredArtifacts) + } + }) + } +} + +func TestIpxeTemplateSQLDAO_Get(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + created := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + + tests := []struct { + desc string + id uuid.UUID + expectError bool + }{ + {desc: "existing template", id: created.ID}, + {desc: "not found", id: uuid.New(), expectError: true}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := dao.Get(ctx, nil, tc.id) + assert.Equal(t, tc.expectError, err != nil) + if !tc.expectError { + require.NotNil(t, got) + assert.Equal(t, tc.id, got.ID) + assert.Equal(t, "kernel-initrd", got.Name) + } + }) + } +} + +func TestIpxeTemplateSQLDAO_GetAll(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + t1 := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + testIpxeTemplateCreate(ctx, t, dao, "ubuntu-autoinstall", IpxeTemplateScopePublic) + testIpxeTemplateCreate(ctx, t, dao, "discovery-scout-x86_64", IpxeTemplateScopeInternal) + + tests := []struct { + desc string + filter IpxeTemplateFilterInput + page paginator.PageInput + expectedCount int + expectedTotal *int + }{ + {desc: "no filter returns all", expectedCount: 3, expectedTotal: cutil.GetPtr(3)}, + {desc: "filter by id", filter: IpxeTemplateFilterInput{IDs: []uuid.UUID{t1.ID}}, expectedCount: 1}, + {desc: "filter by name", filter: IpxeTemplateFilterInput{Names: []string{"kernel-initrd"}}, expectedCount: 1}, + {desc: "limit applies", page: paginator.PageInput{Offset: cutil.GetPtr(0), Limit: cutil.GetPtr(2)}, expectedCount: 2, expectedTotal: cutil.GetPtr(3)}, + {desc: "offset applies", page: paginator.PageInput{Offset: cutil.GetPtr(1)}, expectedCount: 2, expectedTotal: cutil.GetPtr(3)}, + {desc: "unknown id returns empty", filter: IpxeTemplateFilterInput{IDs: []uuid.UUID{uuid.New()}}, expectedCount: 0}, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, total, err := dao.GetAll(ctx, nil, tc.filter, tc.page) + require.NoError(t, err) + assert.Equal(t, tc.expectedCount, len(got)) + if tc.expectedTotal != nil { + assert.Equal(t, *tc.expectedTotal, total) + } + }) + } +} + +func TestIpxeTemplateSQLDAO_Update(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + created := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopeInternal) + + updated, err := dao.Update(ctx, nil, IpxeTemplateUpdateInput{ + IpxeTemplateID: created.ID, + Name: "kernel-initrd", + RequiredParams: []string{"kernel_params", "extra_option"}, + ReservedParams: []string{"base_url"}, + RequiredArtifacts: []string{"kernel"}, + Scope: IpxeTemplateScopePublic, + }) + require.NoError(t, err) + require.NotNil(t, updated) + + assert.Equal(t, created.ID, updated.ID) + assert.Equal(t, IpxeTemplateScopePublic, updated.Scope) + assert.Equal(t, []string{"kernel_params", "extra_option"}, updated.RequiredParams) + assert.Equal(t, []string{"base_url"}, updated.ReservedParams) + assert.Equal(t, []string{"kernel"}, updated.RequiredArtifacts) + assert.Equal(t, "kernel-initrd", updated.Name) +} + +func TestIpxeTemplateSQLDAO_Delete(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + t1 := testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + testIpxeTemplateCreate(ctx, t, dao, "ubuntu-autoinstall", IpxeTemplateScopePublic) + + err := dao.Delete(ctx, nil, t1.ID) + require.NoError(t, err) + + _, err = dao.Get(ctx, nil, t1.ID) + assert.ErrorIs(t, err, db.ErrDoesNotExist) + + remaining, total, err := dao.GetAll(ctx, nil, IpxeTemplateFilterInput{}, paginator.PageInput{}) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Equal(t, "ubuntu-autoinstall", remaining[0].Name) + + err = dao.Delete(ctx, nil, uuid.New()) + assert.NoError(t, err) +} + +func TestIpxeTemplateSQLDAO_UniqueConstraint(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + testIpxeTemplateCreate(ctx, t, dao, "kernel-initrd", IpxeTemplateScopePublic) + + // Names are now globally unique. + _, err := dao.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "kernel-initrd", + Scope: IpxeTemplateScopePublic, + }) + assert.Error(t, err) +} + +func TestIpxeTemplateSQLDAO_DefaultArrayFields(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSetupSchema(t, dbSession) + + dao := NewIpxeTemplateDAO(dbSession) + + created, err := dao.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "ipxe-shell", + Scope: IpxeTemplateScopeInternal, + }) + require.NoError(t, err) + + retrieved, err := dao.Get(ctx, nil, created.ID) + require.NoError(t, err) + assert.NotNil(t, retrieved.RequiredParams) + assert.NotNil(t, retrieved.ReservedParams) + assert.NotNil(t, retrieved.RequiredArtifacts) +} diff --git a/rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go new file mode 100644 index 0000000000..beb4f910af --- /dev/null +++ b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go @@ -0,0 +1,270 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "database/sql" + "time" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" + "github.com/google/uuid" + "github.com/uptrace/bun" + + stracer "github.com/NVIDIA/infra-controller/rest-api/db/pkg/tracer" +) + +const ( + // IpxeTemplateSiteAssociationOrderByDefault default field used for ordering when none specified + IpxeTemplateSiteAssociationOrderByDefault = "created" +) + +var ( + // IpxeTemplateSiteAssociationOrderByFields is a list of valid order by fields for the IpxeTemplateSiteAssociation model + IpxeTemplateSiteAssociationOrderByFields = []string{"created", "updated"} + + // IpxeTemplateSiteAssociationRelatedEntities is a list of valid relation by fields for the IpxeTemplateSiteAssociation model + IpxeTemplateSiteAssociationRelatedEntities = map[string]bool{ + IpxeTemplateRelationName: true, + SiteRelationName: true, + } +) + +// IpxeTemplateSiteAssociation records the availability of an IpxeTemplate at a Site. +// +// Unlike OSSA/SKGSA, REST is not the source of truth for templates (they flow from +// the site agent into REST), so this association does not track sync status, version, +// or controller state. The presence of a row indicates the template is available at +// the site; the row is removed when the site agent stops reporting the template. +type IpxeTemplateSiteAssociation struct { + bun.BaseModel `bun:"table:ipxe_template_site_association,alias:itsa"` + + ID uuid.UUID `bun:"type:uuid,pk"` + IpxeTemplateID uuid.UUID `bun:"ipxe_template_id,type:uuid,notnull"` + IpxeTemplate *IpxeTemplate `bun:"rel:belongs-to,join:ipxe_template_id=id"` + SiteID uuid.UUID `bun:"site_id,type:uuid,notnull"` + Site *Site `bun:"rel:belongs-to,join:site_id=id"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` +} + +// IpxeTemplateSiteAssociationCreateInput input parameters for the Create method +type IpxeTemplateSiteAssociationCreateInput struct { + IpxeTemplateID uuid.UUID + SiteID uuid.UUID +} + +// IpxeTemplateSiteAssociationFilterInput input parameters for the GetAll method +type IpxeTemplateSiteAssociationFilterInput struct { + IpxeTemplateIDs []uuid.UUID + SiteIDs []uuid.UUID +} + +var _ bun.BeforeAppendModelHook = (*IpxeTemplateSiteAssociation)(nil) + +// BeforeAppendModel is a hook called before the model is appended to the query +func (itsa *IpxeTemplateSiteAssociation) BeforeAppendModel(ctx context.Context, query bun.Query) error { + switch query.(type) { + case *bun.InsertQuery: + itsa.Created = db.GetCurTime() + itsa.Updated = db.GetCurTime() + case *bun.UpdateQuery: + itsa.Updated = db.GetCurTime() + } + return nil +} + +var _ bun.BeforeCreateTableHook = (*IpxeTemplateSiteAssociation)(nil) + +// BeforeCreateTable is a hook called before the table is created +func (itsa *IpxeTemplateSiteAssociation) BeforeCreateTable(ctx context.Context, query *bun.CreateTableQuery) error { + query.ForeignKey(`("site_id") REFERENCES "site" ("id")`). + ForeignKey(`("ipxe_template_id") REFERENCES "ipxe_template" ("id") ON DELETE CASCADE`) + return nil +} + +// IpxeTemplateSiteAssociationDAO is an interface for interacting with the IpxeTemplateSiteAssociation model +type IpxeTemplateSiteAssociationDAO interface { + // Create inserts a new association row + Create(ctx context.Context, tx *db.Tx, input IpxeTemplateSiteAssociationCreateInput) (*IpxeTemplateSiteAssociation, error) + // GetByID returns a row by primary key + GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) + // GetByIpxeTemplateIDAndSiteID returns the row matching the (template, site) pair + GetByIpxeTemplateIDAndSiteID(ctx context.Context, tx *db.Tx, ipxeTemplateID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) + // GetAll returns all rows matching the filter and page inputs + GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateSiteAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]IpxeTemplateSiteAssociation, int, error) + // Delete removes a row by ID + Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error +} + +// IpxeTemplateSiteAssociationSQLDAO is an implementation of the IpxeTemplateSiteAssociationDAO interface +type IpxeTemplateSiteAssociationSQLDAO struct { + dbSession *db.Session + IpxeTemplateSiteAssociationDAO + tracerSpan *stracer.TracerSpan +} + +// Create creates a new IpxeTemplateSiteAssociation +func (itsasd IpxeTemplateSiteAssociationSQLDAO) Create( + ctx context.Context, tx *db.Tx, + input IpxeTemplateSiteAssociationCreateInput, +) (*IpxeTemplateSiteAssociation, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.Create") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "ipxe_template_id", input.IpxeTemplateID.String()) + itsasd.tracerSpan.SetAttribute(span, "site_id", input.SiteID.String()) + } + + itsa := &IpxeTemplateSiteAssociation{ + ID: uuid.New(), + IpxeTemplateID: input.IpxeTemplateID, + SiteID: input.SiteID, + } + + _, err := db.GetIDB(tx, itsasd.dbSession).NewInsert().Model(itsa).Exec(ctx) + if err != nil { + return nil, err + } + + return itsasd.GetByID(ctx, tx, itsa.ID, nil) +} + +// GetByID returns an IpxeTemplateSiteAssociation by ID +// Returns db.ErrDoesNotExist if the record is not found +func (itsasd IpxeTemplateSiteAssociationSQLDAO) GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.GetByID") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "id", id.String()) + } + + itsa := &IpxeTemplateSiteAssociation{} + + query := db.GetIDB(tx, itsasd.dbSession).NewSelect().Model(itsa).Where("itsa.id = ?", id) + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + err := query.Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return itsa, nil +} + +// GetByIpxeTemplateIDAndSiteID returns an IpxeTemplateSiteAssociation by (template, site). +// Returns db.ErrDoesNotExist if the record is not found. +func (itsasd IpxeTemplateSiteAssociationSQLDAO) GetByIpxeTemplateIDAndSiteID(ctx context.Context, tx *db.Tx, ipxeTemplateID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*IpxeTemplateSiteAssociation, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.GetByIpxeTemplateIDAndSiteID") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "ipxe_template_id", ipxeTemplateID.String()) + itsasd.tracerSpan.SetAttribute(span, "site_id", siteID.String()) + } + + itsa := &IpxeTemplateSiteAssociation{} + + query := db.GetIDB(tx, itsasd.dbSession).NewSelect().Model(itsa). + Where("itsa.ipxe_template_id = ?", ipxeTemplateID). + Where("itsa.site_id = ?", siteID) + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + err := query.Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return itsa, nil +} + +// GetAll returns all IpxeTemplateSiteAssociation rows with optional filters +func (itsasd IpxeTemplateSiteAssociationSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter IpxeTemplateSiteAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]IpxeTemplateSiteAssociation, int, error) { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.GetAll") + if span != nil { + defer span.End() + } + + itsas := []IpxeTemplateSiteAssociation{} + + query := db.GetIDB(tx, itsasd.dbSession).NewSelect().Model(&itsas) + if len(filter.IpxeTemplateIDs) > 0 { + query = query.Where("itsa.ipxe_template_id IN (?)", bun.In(filter.IpxeTemplateIDs)) + if span != nil { + itsasd.tracerSpan.SetAttribute(span, "ipxe_template_ids", filter.IpxeTemplateIDs) + } + } + if len(filter.SiteIDs) > 0 { + query = query.Where("itsa.site_id IN (?)", bun.In(filter.SiteIDs)) + if span != nil { + itsasd.tracerSpan.SetAttribute(span, "site_ids", filter.SiteIDs) + } + } + + for _, relation := range includeRelations { + query = query.Relation(relation) + } + + if page.OrderBy == nil { + page.OrderBy = paginator.NewDefaultOrderBy(IpxeTemplateSiteAssociationOrderByDefault) + } + + pager, err := paginator.NewPaginator(ctx, query, page.Offset, page.Limit, page.OrderBy, IpxeTemplateSiteAssociationOrderByFields) + if err != nil { + return nil, 0, err + } + + err = pager.Query.Limit(pager.Limit).Offset(pager.Offset).Scan(ctx) + if err != nil { + return nil, 0, err + } + + return itsas, pager.Total, nil +} + +// Delete removes an IpxeTemplateSiteAssociation by ID +func (itsasd IpxeTemplateSiteAssociationSQLDAO) Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error { + ctx, span := itsasd.tracerSpan.CreateChildInCurrentContext(ctx, "IpxeTemplateSiteAssociationDAO.Delete") + if span != nil { + defer span.End() + itsasd.tracerSpan.SetAttribute(span, "id", id.String()) + } + + itsa := &IpxeTemplateSiteAssociation{ID: id} + + _, err := db.GetIDB(tx, itsasd.dbSession).NewDelete().Model(itsa).Where("itsa.id = ?", id).Exec(ctx) + return err +} + +// NewIpxeTemplateSiteAssociationDAO returns a new IpxeTemplateSiteAssociationDAO +func NewIpxeTemplateSiteAssociationDAO(dbSession *db.Session) IpxeTemplateSiteAssociationDAO { + return &IpxeTemplateSiteAssociationSQLDAO{ + dbSession: dbSession, + tracerSpan: stracer.NewTracerSpan(), + } +} diff --git a/rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go new file mode 100644 index 0000000000..5fd19f4806 --- /dev/null +++ b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "context" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + "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" +) + +func testIpxeTemplateSiteAssociationSetupSchema(t *testing.T, dbSession *db.Session) { + ctx := context.Background() + require.Nil(t, dbSession.DB.ResetModel(ctx, (*User)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*InfrastructureProvider)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*Site)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*IpxeTemplate)(nil))) + require.Nil(t, dbSession.DB.ResetModel(ctx, (*IpxeTemplateSiteAssociation)(nil))) + + _, err := dbSession.DB.Exec("ALTER TABLE ipxe_template DROP CONSTRAINT IF EXISTS ipxe_template_name_key") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template ADD CONSTRAINT ipxe_template_name_key UNIQUE (name)") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template_site_association DROP CONSTRAINT IF EXISTS ipxe_template_site_association_template_id_site_id_key") + require.Nil(t, err) + _, err = dbSession.DB.Exec("ALTER TABLE ipxe_template_site_association ADD CONSTRAINT ipxe_template_site_association_template_id_site_id_key UNIQUE (ipxe_template_id, site_id)") + require.Nil(t, err) +} + +func TestIpxeTemplateSiteAssociationSQLDAO_CreateGetDelete(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSiteAssociationSetupSchema(t, dbSession) + + user := TestBuildUser(t, dbSession, "test-user", "test-org", []string{"admin"}) + ip := TestBuildInfrastructureProvider(t, dbSession, "test-provider", "test-org", user) + site := TestBuildSite(t, dbSession, ip, "test-site", user) + + tmplDAO := NewIpxeTemplateDAO(dbSession) + tmpl, err := tmplDAO.Create(ctx, nil, IpxeTemplateCreateInput{ + ID: uuid.New(), Name: "kernel-initrd", Scope: IpxeTemplateScopePublic, + }) + require.NoError(t, err) + + dao := NewIpxeTemplateSiteAssociationDAO(dbSession) + + itsa, err := dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{ + IpxeTemplateID: tmpl.ID, + SiteID: site.ID, + }) + require.NoError(t, err) + assert.Equal(t, tmpl.ID, itsa.IpxeTemplateID) + assert.Equal(t, site.ID, itsa.SiteID) + + got, err := dao.GetByID(ctx, nil, itsa.ID, nil) + require.NoError(t, err) + assert.Equal(t, itsa.ID, got.ID) + + got, err = dao.GetByIpxeTemplateIDAndSiteID(ctx, nil, tmpl.ID, site.ID, nil) + require.NoError(t, err) + assert.Equal(t, itsa.ID, got.ID) + + _, err = dao.GetByIpxeTemplateIDAndSiteID(ctx, nil, uuid.New(), site.ID, nil) + assert.ErrorIs(t, err, db.ErrDoesNotExist) + + require.NoError(t, dao.Delete(ctx, nil, itsa.ID)) + _, err = dao.GetByID(ctx, nil, itsa.ID, nil) + assert.ErrorIs(t, err, db.ErrDoesNotExist) +} + +func TestIpxeTemplateSiteAssociationSQLDAO_GetAllAndUniqueness(t *testing.T) { + ctx := context.Background() + dbSession := testIpxeTemplateInitDB(t) + defer dbSession.Close() + testIpxeTemplateSiteAssociationSetupSchema(t, dbSession) + + user := TestBuildUser(t, dbSession, "test-user", "test-org", []string{"admin"}) + ip := TestBuildInfrastructureProvider(t, dbSession, "test-provider", "test-org", user) + site1 := TestBuildSite(t, dbSession, ip, "site-1", user) + site2 := TestBuildSite(t, dbSession, ip, "site-2", user) + + tmplDAO := NewIpxeTemplateDAO(dbSession) + tmpl1, err := tmplDAO.Create(ctx, nil, IpxeTemplateCreateInput{ID: uuid.New(), Name: "tmpl-a", Scope: IpxeTemplateScopePublic}) + require.NoError(t, err) + tmpl2, err := tmplDAO.Create(ctx, nil, IpxeTemplateCreateInput{ID: uuid.New(), Name: "tmpl-b", Scope: IpxeTemplateScopePublic}) + require.NoError(t, err) + + dao := NewIpxeTemplateSiteAssociationDAO(dbSession) + + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl1.ID, SiteID: site1.ID}) + require.NoError(t, err) + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl1.ID, SiteID: site2.ID}) + require.NoError(t, err) + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl2.ID, SiteID: site1.ID}) + require.NoError(t, err) + + // Duplicate (template, site) pair must fail + _, err = dao.Create(ctx, nil, IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl1.ID, SiteID: site1.ID}) + assert.Error(t, err) + + rows, total, err := dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 3, total) + assert.Len(t, rows, 3) + + rows, total, err = dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{SiteIDs: []uuid.UUID{site1.ID}}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 2, total) + assert.Len(t, rows, 2) + + rows, total, err = dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{IpxeTemplateIDs: []uuid.UUID{tmpl1.ID}}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 2, total) + assert.Len(t, rows, 2) + + rows, total, err = dao.GetAll(ctx, nil, IpxeTemplateSiteAssociationFilterInput{ + IpxeTemplateIDs: []uuid.UUID{tmpl1.ID}, + SiteIDs: []uuid.UUID{site2.ID}, + }, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Len(t, rows, 1) +} diff --git a/rest-api/db/pkg/db/model/operatingsystem.go b/rest-api/db/pkg/db/model/operatingsystem.go index ee80e71e20..15d19c604b 100644 --- a/rest-api/db/pkg/db/model/operatingsystem.go +++ b/rest-api/db/pkg/db/model/operatingsystem.go @@ -8,15 +8,16 @@ import ( "database/sql" "time" - "github.com/google/uuid" - "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + "github.com/google/uuid" "github.com/uptrace/bun" stracer "github.com/NVIDIA/infra-controller/rest-api/db/pkg/tracer" - cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" + + ws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" ) const ( @@ -37,8 +38,17 @@ const ( // OperatingSystemRelationName is the relation name for the OperatingSystem model OperatingSystemRelationName = "OperatingSystem" - // OperatingSystemTypeIPXE is the ipxe based OperatingSystem type + + // 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" + // OperatingSystemTypeIPXE is the raw iPXE script based OperatingSystem type OperatingSystemTypeIPXE = "iPXE" + // OperatingSystemTypeTemplatedIPXE is the iPXE template based OperatingSystem type + OperatingSystemTypeTemplatedIPXE = "Templated iPXE" // OperatingSystemTypeImage is the image based OperatingSystem type OperatingSystemTypeImage = "Image" @@ -49,6 +59,15 @@ const ( OperatingSystemAuthTypeBasic = "Basic" // OperatingSystemAuthTypeBearer is the bearer image auth type OperatingSystemAuthTypeBearer = "Bearer" + + // OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded is the cache as needed strategy + OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded = "CacheAsNeeded" + // OperatingSystemIpxeArtifactCacheStrategyLocalOnly is the local only strategy + OperatingSystemIpxeArtifactCacheStrategyLocalOnly = "LocalOnly" + // OperatingSystemIpxeArtifactCacheStrategyCachedOnly is the cached only strategy + OperatingSystemIpxeArtifactCacheStrategyCachedOnly = "CachedOnly" + // OperatingSystemIpxeArtifactCacheStrategyRemoteOnly is the remote only strategy + OperatingSystemIpxeArtifactCacheStrategyRemoteOnly = "RemoteOnly" ) var ( @@ -71,173 +90,253 @@ var ( } //OperatingSystemsTypeMap is a list of valid type for the OperatingSystem model OperatingSystemsTypeMap = map[string]bool{ - OperatingSystemTypeIPXE: true, - OperatingSystemTypeImage: true, + OperatingSystemTypeIPXE: true, + OperatingSystemTypeTemplatedIPXE: true, + OperatingSystemTypeImage: true, + } + + OperatingSystemTypeFromProtoMap = map[ws.OperatingSystemType]string{ + ws.OperatingSystemType_OS_TYPE_IPXE: OperatingSystemTypeIPXE, + ws.OperatingSystemType_OS_TYPE_TEMPLATED_IPXE: OperatingSystemTypeTemplatedIPXE, + } + + OperatingSystemStatusFromProtoMap = map[ws.TenantState]string{ + ws.TenantState_PROVISIONING: OperatingSystemStatusProvisioning, + ws.TenantState_READY: OperatingSystemStatusReady, + ws.TenantState_CONFIGURING: OperatingSystemStatusSyncing, + ws.TenantState_TERMINATING: OperatingSystemStatusDeleting, + ws.TenantState_FAILED: OperatingSystemStatusError, + } + + OperatingSystemIpxeArtifactCacheStrategyFromProtoMap = map[ws.IpxeTemplateArtifactCacheStrategy]string{ + ws.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED: OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded, + ws.IpxeTemplateArtifactCacheStrategy_LOCAL_ONLY: OperatingSystemIpxeArtifactCacheStrategyLocalOnly, + ws.IpxeTemplateArtifactCacheStrategy_CACHED_ONLY: OperatingSystemIpxeArtifactCacheStrategyCachedOnly, + ws.IpxeTemplateArtifactCacheStrategy_REMOTE_ONLY: OperatingSystemIpxeArtifactCacheStrategyRemoteOnly, + } + + OperatingSystemIpxeArtifactCacheStrategyToProtoMap = map[string]ws.IpxeTemplateArtifactCacheStrategy{ + OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded: ws.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED, + OperatingSystemIpxeArtifactCacheStrategyLocalOnly: ws.IpxeTemplateArtifactCacheStrategy_LOCAL_ONLY, + OperatingSystemIpxeArtifactCacheStrategyCachedOnly: ws.IpxeTemplateArtifactCacheStrategy_CACHED_ONLY, + OperatingSystemIpxeArtifactCacheStrategyRemoteOnly: ws.IpxeTemplateArtifactCacheStrategy_REMOTE_ONLY, } ) -// OperatingSystem describes the attributes of the operating system -// that can be used on instances -type OperatingSystem struct { - bun.BaseModel `bun:"table:operating_system,alias:os"` +// IsIPXEType returns true if the given OS type is any iPXE variant (raw script or templated). +func IsIPXEType(osType string) bool { + return osType == OperatingSystemTypeIPXE || osType == OperatingSystemTypeTemplatedIPXE +} + +// OperatingSystemIpxeParameter holds a single iPXE parameter name/value pair (stored as JSONB). +// These are only populated for iPXE-based OS definitions synced from nico-core. +type OperatingSystemIpxeParameter struct { + Name string `json:"name"` + Value string `json:"value"` +} - ID uuid.UUID `bun:"type:uuid,pk"` - Name string `bun:"name,notnull"` - Description *string `bun:"description"` - Org string `bun:"org,notnull"` - InfrastructureProviderID *uuid.UUID `bun:"infrastructure_provider_id,type:uuid"` - InfrastructureProvider *InfrastructureProvider `bun:"rel:belongs-to,join:infrastructure_provider_id=id"` - TenantID *uuid.UUID `bun:"tenant_id,type:uuid"` - Tenant *Tenant `bun:"rel:belongs-to,join:tenant_id=id"` - ControllerOperatingSystemID *uuid.UUID `bun:"controller_operating_system_id,type:uuid"` - Version *string `bun:"version"` - Type string `bun:"type,notnull"` - ImageURL *string `bun:"image_url"` - ImageSHA *string `bun:"image_sha"` - ImageAuthType *string `bun:"image_auth_type"` - ImageAuthToken *string `bun:"image_auth_token"` - ImageDisk *string `bun:"image_disk"` - RootFsID *string `bun:"root_fs_id"` - RootFsLabel *string `bun:"root_fs_label"` - IpxeScript *string `bun:"ipxe_script"` - UserData *string `bun:"user_data"` - IsCloudInit bool `bun:"is_cloud_init,notnull"` - 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"` +// FromProto converts a proto IpxeTemplateParameter to an OperatingSystemIpxeParameter +func (osip *OperatingSystemIpxeParameter) FromProto(protoParam *ws.IpxeTemplateParameter) { + osip.Name = protoParam.Name + osip.Value = protoParam.Value } -// GetSiteID returns the OperatingSystem ID to use when communicating -// with the Site: ControllerOperatingSystemID when present, otherwise -// the OS's own ID. The Site treats both as opaque identifiers. -func (os *OperatingSystem) GetSiteID() *uuid.UUID { - if os.ControllerOperatingSystemID != nil { - return os.ControllerOperatingSystemID +// ToProto converts an OperatingSystemIpxeParameter to a proto IpxeTemplateParameter +func (osip *OperatingSystemIpxeParameter) ToProto() *ws.IpxeTemplateParameter { + return &ws.IpxeTemplateParameter{ + Name: osip.Name, + Value: osip.Value, } - return &os.ID } -// ToImageAttributesProto builds the OsImageAttributes proto used by -// both the create and update workflows. tenantOrg is the owning -// tenant's organization id (not stored on the entity directly). -// -// The same proto shape is sent for both create and update flows, so -// this entity-level method is the canonical entity-to-proto for OS -// image data; the request-shape ToProto methods on -// APIOperatingSystemCreateRequest and APIOperatingSystemUpdateRequest -// layer on top of it without altering the wire fields. +// OperatingSystemIpxeArtifact holds a single iPXE artifact descriptor (stored as JSONB). +// These are only populated for iPXE-based OS definitions synced from nico-core. // -// Per the proto-conversion convention, the method trusts the caller: -// the request must have been Validated and the handler must have -// performed the cross-context check that the OS is image-typed (the -// dereferences below assume ImageURL and ImageSHA are non-nil, which -// holds for image-typed records after validation). -func (os *OperatingSystem) ToImageAttributesProto(tenantOrg string) *cwssaws.OsImageAttributes { - return &cwssaws.OsImageAttributes{ - Id: &cwssaws.UUID{Value: os.GetSiteID().String()}, - Name: &os.Name, - TenantOrganizationId: tenantOrg, - Description: os.Description, - SourceUrl: *os.ImageURL, - Digest: *os.ImageSHA, - CreateVolume: os.EnableBlockStorage, - AuthType: os.ImageAuthType, - AuthToken: os.ImageAuthToken, - RootfsId: os.RootFsID, - RootfsLabel: os.RootFsLabel, +// Note: the proto IpxeTemplateArtifact has a `cached_url` field that is +// intentionally NOT represented here. cached_url is a per-site value populated +// by nico-core after a successful download; there is no meaningful global +// value for it on the rest side. The push activity must therefore never +// emit cached_url to core (so existing per-site values are preserved), and +// the inbound (pull) activity must never store cached_url on the global +// OperatingSystem row. +type OperatingSystemIpxeArtifact struct { + Name string `json:"name"` + URL string `json:"url"` + SHA *string `json:"sha"` + AuthType *string `json:"authType"` + AuthToken *string `json:"authToken"` + CacheStrategy string `json:"cacheStrategy"` +} + +// FromProto converts a proto IpxeTemplateArtifact to an OperatingSystemIpxeArtifact. +// The proto's cached_url field is intentionally ignored — see the type doc. +func (osia *OperatingSystemIpxeArtifact) FromProto(protoArtifact *ws.IpxeTemplateArtifact) { + osia.Name = protoArtifact.Name + osia.URL = protoArtifact.Url + osia.SHA = protoArtifact.Sha + osia.AuthType = protoArtifact.AuthType + osia.AuthToken = protoArtifact.AuthToken + + cacheStrategy := OperatingSystemIpxeArtifactCacheStrategyFromProtoMap[protoArtifact.CacheStrategy] + if cacheStrategy == "" { + cacheStrategy = OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded } + osia.CacheStrategy = cacheStrategy } -// ToDeletionRequestProto builds the workflow request that asks a Site -// to delete this OS image. -func (os *OperatingSystem) ToDeletionRequestProto(tenantOrg string) *cwssaws.DeleteOsImageRequest { - return &cwssaws.DeleteOsImageRequest{ - Id: &cwssaws.UUID{Value: os.GetSiteID().String()}, - TenantOrganizationId: tenantOrg, +// ToProto converts an OperatingSystemIpxeArtifact to a proto IpxeTemplateArtifact +func (osia *OperatingSystemIpxeArtifact) ToProto() *ws.IpxeTemplateArtifact { + return &ws.IpxeTemplateArtifact{ + Name: osia.Name, + Url: osia.URL, + Sha: osia.SHA, + AuthType: osia.AuthType, + AuthToken: osia.AuthToken, + CacheStrategy: OperatingSystemIpxeArtifactCacheStrategyToProtoMap[osia.CacheStrategy], + CachedUrl: nil, // rest side never update core local value for CachedUrl: it is managed on the core side. } } +// OperatingSystem describes the attributes of the operating system +// that can be used on instances +type OperatingSystem struct { + bun.BaseModel `bun:"table:operating_system,alias:os"` + + ID uuid.UUID `bun:"type:uuid,pk"` + Name string `bun:"name,notnull"` + Description *string `bun:"description"` + Org string `bun:"org,notnull"` + InfrastructureProviderID *uuid.UUID `bun:"infrastructure_provider_id,type:uuid"` + InfrastructureProvider *InfrastructureProvider `bun:"rel:belongs-to,join:infrastructure_provider_id=id"` + TenantID *uuid.UUID `bun:"tenant_id,type:uuid"` + Tenant *Tenant `bun:"rel:belongs-to,join:tenant_id=id"` + Version *string `bun:"version"` + Type string `bun:"type,notnull"` + ImageURL *string `bun:"image_url"` + ImageSHA *string `bun:"image_sha"` + ImageAuthType *string `bun:"image_auth_type"` + ImageAuthToken *string `bun:"image_auth_token"` + ImageDisk *string `bun:"image_disk"` + RootFsID *string `bun:"root_fs_id"` + RootFsLabel *string `bun:"root_fs_label"` + IpxeScript *string `bun:"ipxe_script"` + // iPXE fields populated for OS definitions synced from nico-core (type = iPXE) + IpxeTemplateId *string `bun:"ipxe_template_id"` + 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. + // "Local" means bidirectional, provider-owned from nico-core. + // "Global" and "Limited" mean carbide-rest is the source of truth. + // Set for all iPXE types (raw and templated); nil for Image-type OS. + // Tenant raw iPXE is auto-set to "Global"; provider iPXE from core is "Local". + // Legacy records with nil scope are treated as "Local" and backfilled by migration. + IpxeOsScope *string `bun:"ipxe_os_scope"` + UserData *string `bun:"user_data"` + IsCloudInit bool `bun:"is_cloud_init,notnull"` + 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"` +} + // OperatingSystemCreateInput input parameters for Create method type OperatingSystemCreateInput struct { - Name string - Description *string - Org string - InfrastructureProviderID *uuid.UUID - TenantID *uuid.UUID - ControllerOperatingSystemID *uuid.UUID - Version *string - OsType string - ImageURL *string - ImageSHA *string - ImageAuthType *string - ImageAuthToken *string - ImageDisk *string - RootFsId *string - RootFsLabel *string - IpxeScript *string - UserData *string - IsCloudInit bool - AllowOverride bool - EnableBlockStorage bool - PhoneHomeEnabled bool - Status string - CreatedBy uuid.UUID + // ID optionally pre-specifies the primary key. When set (e.g. during inventory sync from + // nico-core), the same UUID is used on both sides. When zero, a new UUID is generated. + ID uuid.UUID + Name string + Description *string + Org string + InfrastructureProviderID *uuid.UUID + TenantID *uuid.UUID + Version *string + OsType string + ImageURL *string + ImageSHA *string + ImageAuthType *string + ImageAuthToken *string + ImageDisk *string + RootFsId *string + RootFsLabel *string + IpxeScript *string + UserData *string + IsCloudInit bool + AllowOverride bool + EnableBlockStorage bool + PhoneHomeEnabled bool + // iPXE definition fields (for nico-core synced iPXE OS definitions) + IpxeTemplateId *string + IpxeTemplateParameters []OperatingSystemIpxeParameter + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact + IpxeOSHash *string + IpxeOsScope *string + Status string + CreatedBy uuid.UUID } // OperatingSystemUpdateInput input parameters for Update method type OperatingSystemUpdateInput struct { - OperatingSystemId uuid.UUID - Name *string - Description *string - Org *string - InfrastructureProviderID *uuid.UUID - TenantID *uuid.UUID - ControllerOperatingSystemID *uuid.UUID - Version *string - OsType *string - ImageURL *string - ImageSHA *string - ImageAuthType *string - ImageAuthToken *string - ImageDisk *string - RootFsId *string - RootFsLabel *string - IpxeScript *string - UserData *string - IsCloudInit *bool - AllowOverride *bool - EnableBlockStorage *bool - PhoneHomeEnabled *bool - IsActive *bool - DeactivationNote *string - Status *string + OperatingSystemId uuid.UUID + Name *string + Description *string + Org *string + InfrastructureProviderID *uuid.UUID + TenantID *uuid.UUID + Version *string + OsType *string + ImageURL *string + ImageSHA *string + ImageAuthType *string + ImageAuthToken *string + ImageDisk *string + RootFsId *string + RootFsLabel *string + IpxeScript *string + UserData *string + IsCloudInit *bool + AllowOverride *bool + EnableBlockStorage *bool + PhoneHomeEnabled *bool + IsActive *bool + DeactivationNote *string + // iPXE definition fields (for nico-core synced iPXE OS definitions) + IpxeTemplateId *string + IpxeTemplateParameters *[]OperatingSystemIpxeParameter + IpxeTemplateArtifacts *[]OperatingSystemIpxeArtifact + IpxeOSHash *string + Scope *string + Status *string } // OperatingSystemClearInput input parameters for Clear method type OperatingSystemClearInput struct { - OperatingSystemId uuid.UUID - Description bool - InfrastructureProviderID bool - TenantID bool - ControllerOperatingSystemID bool - Version bool - ImageURL bool - ImageSHA bool - ImageAuthType bool - ImageAuthToken bool - ImageDisk bool - RootFsId bool - RootFsLabel bool - IpxeScript bool - UserData bool - DeactivationNote bool + OperatingSystemId uuid.UUID + Description bool + InfrastructureProviderID bool + TenantID bool + Version bool + ImageURL bool + ImageSHA bool + ImageAuthType bool + ImageAuthToken bool + ImageDisk bool + RootFsId bool + RootFsLabel bool + IpxeScript bool + UserData bool + DeactivationNote bool + IpxeTemplateId bool + IpxeTemplateParameters bool + IpxeTemplateArtifacts bool + IpxeOSHash bool + Scope bool } type OperatingSystemFilterInput struct { @@ -251,6 +350,20 @@ type OperatingSystemFilterInput struct { SearchQuery *string OperatingSystemIds []uuid.UUID IsActive *bool + // Scopes filters by the scope field (e.g. "Global", "Limited", "Local"). + Scopes []string + // IncludeDeleted includes soft-deleted records in the result. + // Used by the inventory sync to detect and propagate deletions from nico-core. + IncludeDeleted bool + + // ProviderOSVisibleAtSiteIDs restricts provider-owned OS visibility when + // InfrastructureProviderID is set together with TenantIDs (tenant admin view). + // Only provider-owned OSes with at least one site association at one of these + // sites are included. If nil, no cross-ownership provider entries are shown + // alongside tenant entries (default). If set to an empty slice, no provider + // entries match. This field is ignored when InfrastructureProviderID is used + // without TenantIDs (provider-only view). + ProviderOSVisibleAtSiteIDs *[]uuid.UUID } var _ bun.BeforeAppendModelHook = (*OperatingSystem)(nil) @@ -312,34 +425,42 @@ func (ossd OperatingSystemSQLDAO) Create(ctx context.Context, tx *db.Tx, input O ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "name", input.Name) } + id := input.ID + if id == uuid.Nil { + id = uuid.New() + } os := &OperatingSystem{ - ID: uuid.New(), - Name: input.Name, - Description: input.Description, - Org: input.Org, - InfrastructureProviderID: input.InfrastructureProviderID, - TenantID: input.TenantID, - ControllerOperatingSystemID: input.ControllerOperatingSystemID, - Version: input.Version, - Type: input.OsType, - ImageURL: input.ImageURL, - ImageSHA: input.ImageSHA, - ImageAuthType: input.ImageAuthType, - ImageAuthToken: input.ImageAuthToken, - ImageDisk: input.ImageDisk, - RootFsID: input.RootFsId, - RootFsLabel: input.RootFsLabel, - IpxeScript: input.IpxeScript, - UserData: input.UserData, - IsCloudInit: input.IsCloudInit, - AllowOverride: input.AllowOverride, - EnableBlockStorage: input.EnableBlockStorage, - PhoneHomeEnabled: input.PhoneHomeEnabled, + ID: id, + Name: input.Name, + Description: input.Description, + Org: input.Org, + InfrastructureProviderID: input.InfrastructureProviderID, + TenantID: input.TenantID, + Version: input.Version, + Type: input.OsType, + ImageURL: input.ImageURL, + ImageSHA: input.ImageSHA, + ImageAuthType: input.ImageAuthType, + ImageAuthToken: input.ImageAuthToken, + ImageDisk: input.ImageDisk, + RootFsID: input.RootFsId, + RootFsLabel: input.RootFsLabel, + IpxeScript: input.IpxeScript, + UserData: input.UserData, + IsCloudInit: input.IsCloudInit, + AllowOverride: input.AllowOverride, + EnableBlockStorage: input.EnableBlockStorage, + PhoneHomeEnabled: input.PhoneHomeEnabled, // WARNING: there is a bug in 'bun' and we cannot use non-nullable AND default=true at this time: - IsActive: true, // input.IsActive, - DeactivationNote: nil, //input.DeactivationNote, - Status: input.Status, - CreatedBy: input.CreatedBy, + IsActive: true, // input.IsActive, + DeactivationNote: nil, //input.DeactivationNote, + Status: input.Status, + CreatedBy: input.CreatedBy, + IpxeTemplateId: input.IpxeTemplateId, + IpxeTemplateParameters: input.IpxeTemplateParameters, + IpxeTemplateArtifacts: input.IpxeTemplateArtifacts, + IpxeTemplateDefinitionHash: input.IpxeOSHash, + IpxeOsScope: input.IpxeOsScope, } _, err := db.GetIDB(tx, ossd.dbSession).NewInsert().Model(os).Exec(ctx) @@ -413,13 +534,39 @@ func (ossd OperatingSystemSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter query = query.Where("os.org IN (?)", bun.In(filter.Orgs)) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "filter.org", filter.Orgs) } - if filter.InfrastructureProviderID != nil { - query = query.Where("os.infrastructure_provider_id = ?", *filter.InfrastructureProviderID) - ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "infrastructure_provider_id", filter.InfrastructureProviderID.String()) - } - if filter.TenantIDs != nil { + hasTenants := len(filter.TenantIDs) > 0 + hasProvider := filter.InfrastructureProviderID != nil + hasSiteScope := filter.ProviderOSVisibleAtSiteIDs != nil + + switch { + case hasTenants && hasProvider && hasSiteScope: + // Tenant admin view: own tenant entries + provider entries at accessible sites. + query = query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + q = q.Where("os.tenant_id IN (?)", bun.In(filter.TenantIDs)) + if len(*filter.ProviderOSVisibleAtSiteIDs) > 0 { + q = q.WhereOr( + "(os.infrastructure_provider_id = ? AND EXISTS (SELECT 1 FROM operating_system_site_association WHERE operating_system_id = os.id AND deleted IS NULL AND site_id IN (?)))", + *filter.InfrastructureProviderID, bun.In(*filter.ProviderOSVisibleAtSiteIDs), + ) + } + return q + }) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_with_provider_at_sites", filter.TenantIDs) + case hasTenants && hasProvider: + // Dual-role view: own tenant entries + own provider entries, no site restriction. + query = query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + return q. + Where("os.tenant_id IN (?)", bun.In(filter.TenantIDs)). + WhereOr("os.infrastructure_provider_id = ?", *filter.InfrastructureProviderID) + }) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_or_provider", filter.TenantIDs) + case hasTenants: query = query.Where("os.tenant_id IN (?)", bun.In(filter.TenantIDs)) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_id", filter.TenantIDs) + case hasProvider: + // Provider-only view: only provider-owned entries. + query = query.Where("os.infrastructure_provider_id = ?", *filter.InfrastructureProviderID) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "infrastructure_provider_id", filter.InfrastructureProviderID.String()) } if filter.OsTypes != nil { query = query.Where("os.type IN (?)", bun.In(filter.OsTypes)) @@ -436,16 +583,16 @@ func (ossd OperatingSystemSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter query = query.Where("os.id IN (?)", bun.In(filter.OperatingSystemIds)) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "ids", filter.OperatingSystemIds) } - searchQuery, searchTokens, ok := db.NormalizeSearchQuery(filter.SearchQuery) - if ok { + if filter.SearchQuery != nil { + normalizedTokens := cutil.GetPtr(db.GetStringToTsQuery(*filter.SearchQuery)) query = query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { return q. - Where("to_tsvector('english', (coalesce(os.name, ' ') || ' ' || coalesce(os.description, ' ') || ' ' || coalesce(os.status, ' '))) @@ to_tsquery('english', ?)", *searchTokens). - WhereOr("os.name ILIKE ?", "%"+searchQuery+"%"). - WhereOr("os.description ILIKE ?", "%"+searchQuery+"%"). - WhereOr("os.status ILIKE ?", "%"+searchQuery+"%") + Where("to_tsvector('english', (coalesce(os.name, ' ') || ' ' || coalesce(os.description, ' ') || ' ' || coalesce(os.status, ' '))) @@ to_tsquery('english', ?)", *normalizedTokens). + WhereOr("os.name ILIKE ?", "%"+*filter.SearchQuery+"%"). + WhereOr("os.description ILIKE ?", "%"+*filter.SearchQuery+"%"). + WhereOr("os.status ILIKE ?", "%"+*filter.SearchQuery+"%") }) - ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "search_query", searchQuery) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "search_query", *filter.SearchQuery) } if filter.Statuses != nil { query = query.Where("os.status IN (?)", bun.In(filter.Statuses)) @@ -455,6 +602,13 @@ 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 { + query = query.Where("COALESCE(os.ipxe_os_scope, 'Local') IN (?)", bun.In(filter.Scopes)) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "scopes", filter.Scopes) + } + if filter.IncludeDeleted { + query = query.WhereAllWithDeleted() + } for _, relation := range includeRelations { query = query.Relation(relation) @@ -521,11 +675,6 @@ func (ossd OperatingSystemSQLDAO) Update(ctx context.Context, tx *db.Tx, input O updatedFields = append(updatedFields, "tenant_id") ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "tenant_id", input.TenantID.String()) } - if input.ControllerOperatingSystemID != nil { - it.ControllerOperatingSystemID = input.ControllerOperatingSystemID - updatedFields = append(updatedFields, "controller_operating_system_id") - ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "controller_operating_system_id", input.ControllerOperatingSystemID.String()) - } if input.Version != nil { it.Version = input.Version updatedFields = append(updatedFields, "version") @@ -616,6 +765,26 @@ func (ossd OperatingSystemSQLDAO) Update(ctx context.Context, tx *db.Tx, input O updatedFields = append(updatedFields, "status") ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "status", *input.Status) } + if input.IpxeTemplateId != nil { + it.IpxeTemplateId = input.IpxeTemplateId + updatedFields = append(updatedFields, "ipxe_template_id") + } + if input.IpxeTemplateParameters != nil { + it.IpxeTemplateParameters = *input.IpxeTemplateParameters + updatedFields = append(updatedFields, "ipxe_template_parameters") + } + if input.IpxeTemplateArtifacts != nil { + it.IpxeTemplateArtifacts = *input.IpxeTemplateArtifacts + updatedFields = append(updatedFields, "ipxe_template_artifacts") + } + if input.IpxeOSHash != nil { + 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") @@ -664,10 +833,6 @@ func (ossd OperatingSystemSQLDAO) Clear(ctx context.Context, tx *db.Tx, input Op it.TenantID = nil updatedFields = append(updatedFields, "tenant_id") } - if input.ControllerOperatingSystemID { - it.ControllerOperatingSystemID = nil - updatedFields = append(updatedFields, "controller_operating_system_id") - } if input.Version { it.Version = nil updatedFields = append(updatedFields, "version") @@ -712,6 +877,26 @@ func (ossd OperatingSystemSQLDAO) Clear(ctx context.Context, tx *db.Tx, input Op it.DeactivationNote = nil updatedFields = append(updatedFields, "deactivation_note") } + if input.IpxeTemplateId { + it.IpxeTemplateId = nil + updatedFields = append(updatedFields, "ipxe_template_id") + } + if input.IpxeTemplateParameters { + it.IpxeTemplateParameters = nil + updatedFields = append(updatedFields, "ipxe_template_parameters") + } + if input.IpxeTemplateArtifacts { + it.IpxeTemplateArtifacts = nil + updatedFields = append(updatedFields, "ipxe_template_artifacts") + } + if input.IpxeOSHash { + 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_test.go b/rest-api/db/pkg/db/model/operatingsystem_test.go index c357a55c90..a3495764f3 100644 --- a/rest-api/db/pkg/db/model/operatingsystem_test.go +++ b/rest-api/db/pkg/db/model/operatingsystem_test.go @@ -4,89 +4,22 @@ package model import ( + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" "context" "fmt" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" otrace "go.opentelemetry.io/otel/trace" - "github.com/google/uuid" - "github.com/uptrace/bun/extra/bundebug" - - cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" stracer "github.com/NVIDIA/infra-controller/rest-api/db/pkg/tracer" "github.com/NVIDIA/infra-controller/rest-api/db/pkg/util" + "github.com/google/uuid" + "github.com/uptrace/bun/extra/bundebug" ) -func TestOperatingSystem_GetSiteID(t *testing.T) { - id := uuid.New() - ctrlID := uuid.New() - t.Run("falls back to ID when ControllerOperatingSystemID is nil", func(t *testing.T) { - os := &OperatingSystem{ID: id} - got := os.GetSiteID() - require.NotNil(t, got) - assert.Equal(t, id, *got) - }) - t.Run("uses ControllerOperatingSystemID when set", func(t *testing.T) { - os := &OperatingSystem{ID: id, ControllerOperatingSystemID: &ctrlID} - got := os.GetSiteID() - require.NotNil(t, got) - assert.Equal(t, ctrlID, *got) - }) -} - -func TestOperatingSystem_ToImageAttributesProto(t *testing.T) { - id := uuid.New() - desc := "primary" - url := "https://image" - sha := "deadbeef" - authType := "Basic" - authToken := "token" - rootFsID := "fs-1" - rootFsLabel := "label" - os := &OperatingSystem{ - ID: id, - Name: "ubuntu", - Description: &desc, - ImageURL: &url, - ImageSHA: &sha, - ImageAuthType: &authType, - ImageAuthToken: &authToken, - RootFsID: &rootFsID, - RootFsLabel: &rootFsLabel, - EnableBlockStorage: true, - } - got := os.ToImageAttributesProto("org-1") - require.NotNil(t, got) - require.NotNil(t, got.Id) - assert.Equal(t, id.String(), got.Id.Value) - require.NotNil(t, got.Name) - assert.Equal(t, "ubuntu", *got.Name) - assert.Equal(t, "org-1", got.TenantOrganizationId) - assert.Equal(t, &desc, got.Description) - assert.Equal(t, "https://image", got.SourceUrl) - assert.Equal(t, "deadbeef", got.Digest) - assert.True(t, got.CreateVolume) - assert.Equal(t, &authType, got.AuthType) - assert.Equal(t, &authToken, got.AuthToken) - assert.Equal(t, &rootFsID, got.RootfsId) - assert.Equal(t, &rootFsLabel, got.RootfsLabel) -} - -func TestOperatingSystem_ToDeletionRequestProto(t *testing.T) { - id := uuid.New() - os := &OperatingSystem{ID: id} - got := os.ToDeletionRequestProto("org-1") - require.NotNil(t, got) - require.NotNil(t, got.Id) - assert.Equal(t, id.String(), got.Id.Value) - assert.Equal(t, "org-1", got.TenantOrganizationId) -} - func testOperatingSystemInitDB(t *testing.T) *db.Session { dbSession := util.GetTestDBSession(t, false) dbSession.DB.AddQueryHook(bundebug.NewQueryHook( @@ -223,29 +156,28 @@ func TestOperatingSystemSQLDAO_Create(t *testing.T) { for _, i := range tc.its { os, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: i.Name, - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: i.InfrastructureProviderID, - TenantID: i.TenantID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "ipxe", - ImageURL: cutil.GetPtr("imageURL"), - ImageSHA: cutil.GetPtr("imageSHA"), - ImageAuthType: cutil.GetPtr("imageAuthType"), - ImageAuthToken: cutil.GetPtr("imageAuthToken"), - ImageDisk: cutil.GetPtr("imageDisk"), - RootFsId: cutil.GetPtr("rootFsId"), - RootFsLabel: cutil.GetPtr("rootFsLabel"), - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: i.PhoneHomeEnabled, - Status: OperatingSystemStatusPending, - CreatedBy: i.CreatedBy, + Name: i.Name, + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: i.InfrastructureProviderID, + TenantID: i.TenantID, + Version: cutil.GetPtr("version"), + OsType: "ipxe", + ImageURL: cutil.GetPtr("imageURL"), + ImageSHA: cutil.GetPtr("imageSHA"), + ImageAuthType: cutil.GetPtr("imageAuthType"), + ImageAuthToken: cutil.GetPtr("imageAuthToken"), + ImageDisk: cutil.GetPtr("imageDisk"), + RootFsId: cutil.GetPtr("rootFsId"), + RootFsLabel: cutil.GetPtr("rootFsLabel"), + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: i.PhoneHomeEnabled, + Status: OperatingSystemStatusPending, + CreatedBy: i.CreatedBy, }, ) assert.Equal(t, tc.expectError, err != nil) @@ -272,53 +204,50 @@ func TestOperatingSystemSQLDAO_GetByID(t *testing.T) { tenant := testOperatingSystemBuildTenant(t, dbSession, "testTenant") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() os1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "test1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "ipxe", - ImageURL: cutil.GetPtr("imageURL"), - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "test1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant.ID, + Version: cutil.GetPtr("version"), + OsType: "ipxe", + ImageURL: cutil.GetPtr("imageURL"), + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1) os2, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "test2", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: nil, - TenantID: nil, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "image", - ImageURL: cutil.GetPtr("imageURL"), - ImageSHA: cutil.GetPtr("imageSHA"), - ImageAuthType: cutil.GetPtr("imageAuthType"), - ImageAuthToken: cutil.GetPtr("imageAuthToken"), - ImageDisk: cutil.GetPtr("imageDisk"), - RootFsId: cutil.GetPtr("rootFsId"), - RootFsLabel: cutil.GetPtr("rootFsLabel"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "test2", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: nil, + TenantID: nil, + Version: cutil.GetPtr("version"), + OsType: "image", + ImageURL: cutil.GetPtr("imageURL"), + ImageSHA: cutil.GetPtr("imageSHA"), + ImageAuthType: cutil.GetPtr("imageAuthType"), + ImageAuthToken: cutil.GetPtr("imageAuthToken"), + ImageDisk: cutil.GetPtr("imageDisk"), + RootFsId: cutil.GetPtr("rootFsId"), + RootFsLabel: cutil.GetPtr("rootFsLabel"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os2) @@ -442,22 +371,21 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { if i%2 == 0 { os, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: fmt.Sprintf("os-%v", i), - Description: cutil.GetPtr("Test Description"), - Org: tenant1.Org, - InfrastructureProviderID: nil, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: cutil.GetPtr("imageURL"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: fmt.Sprintf("os-%v", i), + Description: cutil.GetPtr("Test Description"), + Org: tenant1.Org, + InfrastructureProviderID: nil, + TenantID: &tenant1.ID, + Version: cutil.GetPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: cutil.GetPtr("imageURL"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) ossTenant1 = append(ossTenant1, *os) @@ -474,23 +402,22 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { } else { _, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: fmt.Sprintf("os-%v", i), - Description: cutil.GetPtr("description"), - Org: tenant2.Org, - InfrastructureProviderID: nil, - TenantID: &tenant2.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: OperatingSystemTypeIPXE, - ImageURL: cutil.GetPtr("iPXE"), - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: false, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: fmt.Sprintf("os-%v", i), + Description: cutil.GetPtr("description"), + Org: tenant2.Org, + InfrastructureProviderID: nil, + TenantID: &tenant2.ID, + Version: cutil.GetPtr("version"), + OsType: OperatingSystemTypeIPXE, + ImageURL: cutil.GetPtr("iPXE"), + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: false, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) } @@ -503,72 +430,69 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { ossasSite2 := []OperatingSystemSiteAssociation{} ossasSite3 := []OperatingSystemSiteAssociation{} - joinIpxeOss := []OperatingSystem{} + joinIpxeScripts := []OperatingSystem{} // iPXE image 1 os, _ := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "ipxe-os-1", - Description: cutil.GetPtr("description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: OperatingSystemTypeIPXE, - ImageURL: cutil.GetPtr("iPXE"), - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: false, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "ipxe-os-1", + Description: cutil.GetPtr("description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: cutil.GetPtr("version"), + OsType: OperatingSystemTypeIPXE, + ImageURL: cutil.GetPtr("iPXE"), + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: false, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) - joinIpxeOss = append(joinIpxeOss, *os) + joinIpxeScripts = append(joinIpxeScripts, *os) // iPXE image 2 os, _ = ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "ipxe-os-2", - Description: cutil.GetPtr("description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: OperatingSystemTypeIPXE, - ImageURL: cutil.GetPtr("iPXE"), - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: false, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "ipxe-os-2", + Description: cutil.GetPtr("description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: cutil.GetPtr("version"), + OsType: OperatingSystemTypeIPXE, + ImageURL: cutil.GetPtr("iPXE"), + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: false, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) - joinIpxeOss = append(joinIpxeOss, *os) + joinIpxeScripts = append(joinIpxeScripts, *os) // OS Image 1 for site2 os, _ = ossd.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "image-os-1", - Description: cutil.GetPtr("Test Description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: cutil.GetPtr("imageURL"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "image-os-1", + Description: cutil.GetPtr("Test Description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: cutil.GetPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: cutil.GetPtr("imageURL"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) ossa, _ := ossaDAO.Create(ctx, nil, OperatingSystemSiteAssociationCreateInput{ OperatingSystemID: os.ID, @@ -580,22 +504,21 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { // OS Image 2 for site2 os, _ = ossd.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "image-os-2", - Description: cutil.GetPtr("Test Description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: cutil.GetPtr("imageURL"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "image-os-2", + Description: cutil.GetPtr("Test Description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: cutil.GetPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: cutil.GetPtr("imageURL"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) ossa, _ = ossaDAO.Create(ctx, nil, OperatingSystemSiteAssociationCreateInput{ OperatingSystemID: os.ID, @@ -607,22 +530,21 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { // OS Image 3 for site3 os, _ = ossd.Create(ctx, nil, OperatingSystemCreateInput{ - Name: "image-os-3", - Description: cutil.GetPtr("Test Description"), - Org: tenant4.Org, - InfrastructureProviderID: nil, - TenantID: &tenant4.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: OperatingSystemTypeImage, - ImageURL: cutil.GetPtr("imageURL"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "image-os-3", + Description: cutil.GetPtr("Test Description"), + Org: tenant4.Org, + InfrastructureProviderID: nil, + TenantID: &tenant4.ID, + Version: cutil.GetPtr("version"), + OsType: OperatingSystemTypeImage, + ImageURL: cutil.GetPtr("imageURL"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) ossa, _ = ossaDAO.Create(ctx, nil, OperatingSystemSiteAssociationCreateInput{ OperatingSystemID: os.ID, @@ -835,7 +757,7 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { siteIDs: []uuid.UUID{site.ID}, searchQuery: nil, expectedCount: paginator.DefaultLimit, - expectedTotal: cutil.GetPtr(totalCount + len(joinIpxeOss)), + expectedTotal: cutil.GetPtr(totalCount + len(joinIpxeScripts)), expectedError: false, }, { @@ -890,8 +812,8 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { tenantIDs: nil, osNames: nil, osTypes: []string{OperatingSystemTypeIPXE}, - expectedCount: totalCount/2 + len(joinIpxeOss), - expectedTotal: cutil.GetPtr(totalCount/2 + len(joinIpxeOss)), + expectedCount: totalCount/2 + len(joinIpxeScripts), + expectedTotal: cutil.GetPtr(totalCount/2 + len(joinIpxeScripts)), expectedError: false, }, { @@ -909,7 +831,7 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { ipID: nil, tenantIDs: nil, osNames: nil, - osTypes: []string{OperatingSystemTypeImage, OperatingSystemTypeIPXE}, + osTypes: []string{OperatingSystemTypeIPXE, OperatingSystemTypeImage}, expectedCount: paginator.DefaultLimit, expectedTotal: cutil.GetPtr(totalCount + testJoinCount), expectedError: false, @@ -972,8 +894,8 @@ func TestOperatingSystemSQLDAO_GetAll(t *testing.T) { siteIDs: []uuid.UUID{site3.ID}, osTypes: nil, searchQuery: nil, - expectedCount: len(joinIpxeOss) + len(ossasSite3), - expectedTotal: cutil.GetPtr(len(joinIpxeOss) + len(ossasSite3)), + expectedCount: len(joinIpxeScripts) + len(ossasSite3), + expectedTotal: cutil.GetPtr(len(joinIpxeScripts) + len(ossasSite3)), expectedError: false, }, { @@ -1048,74 +970,69 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { tenant2 := testOperatingSystemBuildTenant(t, dbSession, "testTenant2") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() - updatedUUID := uuid.New() os1tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "ipxe", - ImageURL: cutil.GetPtr("imageURL"), - ImageSHA: cutil.GetPtr("imageSHA"), - ImageAuthType: cutil.GetPtr("imageAuthType"), - ImageAuthToken: cutil.GetPtr("imageAuthToken"), - ImageDisk: cutil.GetPtr("imageDisk"), - RootFsId: cutil.GetPtr("rootFsId"), - RootFsLabel: cutil.GetPtr("rootFsLabel"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: cutil.GetPtr("version"), + OsType: "ipxe", + ImageURL: cutil.GetPtr("imageURL"), + ImageSHA: cutil.GetPtr("imageSHA"), + ImageAuthType: cutil.GetPtr("imageAuthType"), + ImageAuthToken: cutil.GetPtr("imageAuthToken"), + ImageDisk: cutil.GetPtr("imageDisk"), + RootFsId: cutil.GetPtr("rootFsId"), + RootFsLabel: cutil.GetPtr("rootFsLabel"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant1) os2tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os2tenant1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "ipxe", - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os2tenant1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: cutil.GetPtr("version"), + OsType: "ipxe", + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os2tenant1) os1tenant2, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant2.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "ipxe", - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: false, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant2.ID, + Version: cutil.GetPtr("version"), + OsType: "ipxe", + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: false, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant2) @@ -1133,234 +1050,225 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { desc string os *OperatingSystem - paramName *string - paramDescription *string - paramOrg *string - paramInfrastructureProviderID *uuid.UUID - paramTenantID *uuid.UUID - paramControllerOperatingSystemID *uuid.UUID - paramVersion *string - paramType *string - paramImageURL *string - paramImageSHA *string - paramImageAuthType *string - paramImageAuthToken *string - paramImageDisk *string - paramRootFsID *string - paramRootFsLabel *string - paramIpxeScript *string - paramUserData *string - paramIsCloudInit *bool - paramAllowOverride *bool - paramEnableBlockStorage *bool - paramPhoneHomeEnabled *bool - paramIsActive *bool - paramDeactivationNote *string - paramStatus *string - - expectedName *string - expectedDescription *string - expectedOrg *string - expectedInfrastructureProviderID *uuid.UUID - expectedTenantID *uuid.UUID - expectedControllerOperatingSystemID *uuid.UUID - expectedVersion *string - expectedType *string - expectedImageURL *string - expectedImageSHA *string - expectedImageAuthType *string - expectedImageAuthToken *string - expectedImageDisk *string - expectedRootFsID *string - expectedRootFsLabel *string - expectedIpxeScript *string - expectedUserData *string - expectedIsCloudInit *bool - expectedAllowOverride *bool - expectedEnableBlockStorage *bool - expectPhoneHomeEnabled *bool - expectedIsActive *bool - expectedDeactivationNote *string - expectedStatus *string - verifyChildSpanner bool + paramName *string + paramDescription *string + paramOrg *string + paramInfrastructureProviderID *uuid.UUID + paramTenantID *uuid.UUID + paramVersion *string + paramType *string + paramImageURL *string + paramImageSHA *string + paramImageAuthType *string + paramImageAuthToken *string + paramImageDisk *string + paramRootFsID *string + paramRootFsLabel *string + paramIpxeScript *string + paramUserData *string + paramIsCloudInit *bool + paramAllowOverride *bool + paramEnableBlockStorage *bool + paramPhoneHomeEnabled *bool + paramIsActive *bool + paramDeactivationNote *string + paramStatus *string + + expectedName *string + expectedDescription *string + expectedOrg *string + expectedInfrastructureProviderID *uuid.UUID + expectedTenantID *uuid.UUID + expectedVersion *string + expectedType *string + expectedImageURL *string + expectedImageSHA *string + expectedImageAuthType *string + expectedImageAuthToken *string + expectedImageDisk *string + expectedRootFsID *string + expectedRootFsLabel *string + expectedIpxeScript *string + expectedUserData *string + expectedIsCloudInit *bool + expectedAllowOverride *bool + expectedEnableBlockStorage *bool + expectPhoneHomeEnabled *bool + expectedIsActive *bool + expectedDeactivationNote *string + expectedStatus *string + verifyChildSpanner bool }{ { desc: "can update string fields: name, description, org, version, imageurl, imageSHA, imageAuthType, imageAuthToken, imageDisk, rootFsID, rootFsLabel, ipxescript, userdata, status", os: os1tenant1, - paramName: cutil.GetPtr("updatedName"), - paramDescription: cutil.GetPtr("updatedDescription"), - paramOrg: cutil.GetPtr("updatedOrg"), - paramInfrastructureProviderID: nil, - paramTenantID: nil, - paramControllerOperatingSystemID: nil, - paramVersion: cutil.GetPtr("updatedVersion"), - paramType: cutil.GetPtr("updatedType"), - paramImageURL: cutil.GetPtr("updatedImageURL"), - paramImageSHA: cutil.GetPtr("updatedImageSHA"), - paramImageAuthType: cutil.GetPtr("updatedImageAuthType"), - paramImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), - paramImageDisk: cutil.GetPtr("updatedImageDisk"), - paramRootFsID: cutil.GetPtr("updatedRootFsID"), - paramRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), - paramIpxeScript: cutil.GetPtr("updatedIpxeScript"), - paramUserData: cutil.GetPtr("updatedUserData"), - paramIsCloudInit: nil, - paramAllowOverride: nil, - paramEnableBlockStorage: nil, - paramPhoneHomeEnabled: nil, - paramStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), - - expectedName: cutil.GetPtr("updatedName"), - expectedDescription: cutil.GetPtr("updatedDescription"), - expectedOrg: cutil.GetPtr("updatedOrg"), - expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, - expectedTenantID: os1tenant1.TenantID, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: cutil.GetPtr("updatedVersion"), - expectedType: cutil.GetPtr("updatedType"), - expectedImageURL: cutil.GetPtr("updatedImageURL"), - expectedImageSHA: cutil.GetPtr("updatedImageSHA"), - expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), - expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), - expectedImageDisk: cutil.GetPtr("updatedImageDisk"), - expectedRootFsID: cutil.GetPtr("updatedRootFsID"), - expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), - expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), - expectedUserData: cutil.GetPtr("updatedUserData"), - expectedIsCloudInit: &os1tenant1.IsCloudInit, - expectedAllowOverride: &os1tenant1.AllowOverride, - expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, - expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, - expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), - verifyChildSpanner: true, + paramName: cutil.GetPtr("updatedName"), + paramDescription: cutil.GetPtr("updatedDescription"), + paramOrg: cutil.GetPtr("updatedOrg"), + paramInfrastructureProviderID: nil, + paramTenantID: nil, + paramVersion: cutil.GetPtr("updatedVersion"), + paramType: cutil.GetPtr("updatedType"), + paramImageURL: cutil.GetPtr("updatedImageURL"), + paramImageSHA: cutil.GetPtr("updatedImageSHA"), + paramImageAuthType: cutil.GetPtr("updatedImageAuthType"), + paramImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), + paramImageDisk: cutil.GetPtr("updatedImageDisk"), + paramRootFsID: cutil.GetPtr("updatedRootFsID"), + paramRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), + paramIpxeScript: cutil.GetPtr("updatedIpxeScript"), + paramUserData: cutil.GetPtr("updatedUserData"), + paramIsCloudInit: nil, + paramAllowOverride: nil, + paramEnableBlockStorage: nil, + paramPhoneHomeEnabled: nil, + paramStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), + + expectedName: cutil.GetPtr("updatedName"), + expectedDescription: cutil.GetPtr("updatedDescription"), + expectedOrg: cutil.GetPtr("updatedOrg"), + expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, + expectedTenantID: os1tenant1.TenantID, + expectedVersion: cutil.GetPtr("updatedVersion"), + expectedType: cutil.GetPtr("updatedType"), + expectedImageURL: cutil.GetPtr("updatedImageURL"), + expectedImageSHA: cutil.GetPtr("updatedImageSHA"), + expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), + expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), + expectedImageDisk: cutil.GetPtr("updatedImageDisk"), + expectedRootFsID: cutil.GetPtr("updatedRootFsID"), + expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), + expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), + expectedUserData: cutil.GetPtr("updatedUserData"), + expectedIsCloudInit: &os1tenant1.IsCloudInit, + expectedAllowOverride: &os1tenant1.AllowOverride, + expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, + expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, + expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), + verifyChildSpanner: true, }, { desc: "can update uuid fields: infrastructureproviderid, tenantid, controlleroperatingsystemid", os: os1tenant1, - paramName: nil, - paramDescription: nil, - paramOrg: nil, - paramInfrastructureProviderID: &updatedIP.ID, - paramTenantID: &updatedTenant.ID, - paramControllerOperatingSystemID: &updatedUUID, - paramVersion: nil, - paramType: nil, - paramImageURL: nil, - paramImageSHA: nil, - paramImageAuthType: nil, - paramImageAuthToken: nil, - paramImageDisk: nil, - paramRootFsID: nil, - paramRootFsLabel: nil, - paramIpxeScript: nil, - paramUserData: nil, - paramIsCloudInit: nil, - paramAllowOverride: nil, - paramEnableBlockStorage: nil, - paramPhoneHomeEnabled: nil, - paramStatus: nil, - - expectedName: cutil.GetPtr("updatedName"), - expectedDescription: cutil.GetPtr("updatedDescription"), - expectedOrg: cutil.GetPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: cutil.GetPtr("updatedVersion"), - expectedType: cutil.GetPtr("updatedType"), - expectedImageURL: cutil.GetPtr("updatedImageURL"), - expectedImageSHA: cutil.GetPtr("updatedImageSHA"), - expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), - expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), - expectedImageDisk: cutil.GetPtr("updatedImageDisk"), - expectedRootFsID: cutil.GetPtr("updatedRootFsID"), - expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), - expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), - expectedUserData: cutil.GetPtr("updatedUserData"), - expectedIsCloudInit: &os1tenant1.IsCloudInit, - expectedAllowOverride: &os1tenant1.AllowOverride, - expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, - expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, - expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), + paramName: nil, + paramDescription: nil, + paramOrg: nil, + paramInfrastructureProviderID: &updatedIP.ID, + paramTenantID: &updatedTenant.ID, + paramVersion: nil, + paramType: nil, + paramImageURL: nil, + paramImageSHA: nil, + paramImageAuthType: nil, + paramImageAuthToken: nil, + paramImageDisk: nil, + paramRootFsID: nil, + paramRootFsLabel: nil, + paramIpxeScript: nil, + paramUserData: nil, + paramIsCloudInit: nil, + paramAllowOverride: nil, + paramEnableBlockStorage: nil, + paramPhoneHomeEnabled: nil, + paramStatus: nil, + + expectedName: cutil.GetPtr("updatedName"), + expectedDescription: cutil.GetPtr("updatedDescription"), + expectedOrg: cutil.GetPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: cutil.GetPtr("updatedVersion"), + expectedType: cutil.GetPtr("updatedType"), + expectedImageURL: cutil.GetPtr("updatedImageURL"), + expectedImageSHA: cutil.GetPtr("updatedImageSHA"), + expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), + expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), + expectedImageDisk: cutil.GetPtr("updatedImageDisk"), + expectedRootFsID: cutil.GetPtr("updatedRootFsID"), + expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), + expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), + expectedUserData: cutil.GetPtr("updatedUserData"), + expectedIsCloudInit: &os1tenant1.IsCloudInit, + expectedAllowOverride: &os1tenant1.AllowOverride, + expectedEnableBlockStorage: &os1tenant1.EnableBlockStorage, + expectPhoneHomeEnabled: &os1tenant1.PhoneHomeEnabled, + expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), }, { desc: "can update bool fields: iscloudinit, allowcloudinit, isblockstorage", os: os1tenant1, - paramName: nil, - paramDescription: nil, - paramOrg: nil, - paramInfrastructureProviderID: nil, - paramTenantID: nil, - paramControllerOperatingSystemID: nil, - paramVersion: nil, - paramType: nil, - paramImageURL: nil, - paramImageSHA: nil, - paramImageAuthType: nil, - paramImageAuthToken: nil, - paramImageDisk: nil, - paramRootFsID: nil, - paramRootFsLabel: nil, - paramIpxeScript: nil, - paramUserData: nil, - paramIsCloudInit: &updatedIsCloudInit, - paramAllowOverride: &updatedAllowOverride, - paramEnableBlockStorage: &updatedEnableBlockStorage, - paramPhoneHomeEnabled: &updatedPhoneHomeEnabled, - paramStatus: nil, - - expectedName: cutil.GetPtr("updatedName"), - expectedDescription: cutil.GetPtr("updatedDescription"), - expectedOrg: cutil.GetPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: cutil.GetPtr("updatedVersion"), - expectedType: cutil.GetPtr("updatedType"), - expectedImageURL: cutil.GetPtr("updatedImageURL"), - expectedImageSHA: cutil.GetPtr("updatedImageSHA"), - expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), - expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), - expectedImageDisk: cutil.GetPtr("updatedImageDisk"), - expectedRootFsID: cutil.GetPtr("updatedRootFsID"), - expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), - expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), - expectedUserData: cutil.GetPtr("updatedUserData"), - expectedIsCloudInit: &updatedIsCloudInit, - expectedAllowOverride: &updatedAllowOverride, - expectedEnableBlockStorage: &updatedEnableBlockStorage, - expectPhoneHomeEnabled: &updatedEnableBlockStorage, - expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), + paramName: nil, + paramDescription: nil, + paramOrg: nil, + paramInfrastructureProviderID: nil, + paramTenantID: nil, + paramVersion: nil, + paramType: nil, + paramImageURL: nil, + paramImageSHA: nil, + paramImageAuthType: nil, + paramImageAuthToken: nil, + paramImageDisk: nil, + paramRootFsID: nil, + paramRootFsLabel: nil, + paramIpxeScript: nil, + paramUserData: nil, + paramIsCloudInit: &updatedIsCloudInit, + paramAllowOverride: &updatedAllowOverride, + paramEnableBlockStorage: &updatedEnableBlockStorage, + paramPhoneHomeEnabled: &updatedPhoneHomeEnabled, + paramStatus: nil, + + expectedName: cutil.GetPtr("updatedName"), + expectedDescription: cutil.GetPtr("updatedDescription"), + expectedOrg: cutil.GetPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: cutil.GetPtr("updatedVersion"), + expectedType: cutil.GetPtr("updatedType"), + expectedImageURL: cutil.GetPtr("updatedImageURL"), + expectedImageSHA: cutil.GetPtr("updatedImageSHA"), + expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), + expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), + expectedImageDisk: cutil.GetPtr("updatedImageDisk"), + expectedRootFsID: cutil.GetPtr("updatedRootFsID"), + expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), + expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), + expectedUserData: cutil.GetPtr("updatedUserData"), + expectedIsCloudInit: &updatedIsCloudInit, + expectedAllowOverride: &updatedAllowOverride, + expectedEnableBlockStorage: &updatedEnableBlockStorage, + expectPhoneHomeEnabled: &updatedEnableBlockStorage, + expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), }, { desc: "ok when no fields are updated", os: os1tenant1, - expectedName: cutil.GetPtr("updatedName"), - expectedDescription: cutil.GetPtr("updatedDescription"), - expectedOrg: cutil.GetPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: cutil.GetPtr("updatedVersion"), - expectedType: cutil.GetPtr("updatedType"), - expectedImageURL: cutil.GetPtr("updatedImageURL"), - expectedImageSHA: cutil.GetPtr("updatedImageSHA"), - expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), - expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), - expectedImageDisk: cutil.GetPtr("updatedImageDisk"), - expectedRootFsID: cutil.GetPtr("updatedRootFsID"), - expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), - expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), - expectedUserData: cutil.GetPtr("updatedUserData"), - expectedIsCloudInit: &updatedIsCloudInit, - expectedAllowOverride: &updatedAllowOverride, - expectedEnableBlockStorage: &updatedEnableBlockStorage, - expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, - expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), + expectedName: cutil.GetPtr("updatedName"), + expectedDescription: cutil.GetPtr("updatedDescription"), + expectedOrg: cutil.GetPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: cutil.GetPtr("updatedVersion"), + expectedType: cutil.GetPtr("updatedType"), + expectedImageURL: cutil.GetPtr("updatedImageURL"), + expectedImageSHA: cutil.GetPtr("updatedImageSHA"), + expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), + expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), + expectedImageDisk: cutil.GetPtr("updatedImageDisk"), + expectedRootFsID: cutil.GetPtr("updatedRootFsID"), + expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), + expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), + expectedUserData: cutil.GetPtr("updatedUserData"), + expectedIsCloudInit: &updatedIsCloudInit, + expectedAllowOverride: &updatedAllowOverride, + expectedEnableBlockStorage: &updatedEnableBlockStorage, + expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, + expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), }, { desc: "can update isActive from true to false", @@ -1368,60 +1276,58 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { paramIsActive: &updatedIsActive, paramDeactivationNote: &updatedDeactivationNote, - expectedName: cutil.GetPtr("updatedName"), - expectedDescription: cutil.GetPtr("updatedDescription"), - expectedOrg: cutil.GetPtr("updatedOrg"), - expectedInfrastructureProviderID: &updatedIP.ID, - expectedTenantID: &updatedTenant.ID, - expectedControllerOperatingSystemID: &updatedUUID, - expectedVersion: cutil.GetPtr("updatedVersion"), - expectedType: cutil.GetPtr("updatedType"), - expectedImageURL: cutil.GetPtr("updatedImageURL"), - expectedImageSHA: cutil.GetPtr("updatedImageSHA"), - expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), - expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), - expectedImageDisk: cutil.GetPtr("updatedImageDisk"), - expectedRootFsID: cutil.GetPtr("updatedRootFsID"), - expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), - expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), - expectedUserData: cutil.GetPtr("updatedUserData"), - expectedIsCloudInit: &updatedIsCloudInit, - expectedAllowOverride: &updatedAllowOverride, - expectedEnableBlockStorage: &updatedEnableBlockStorage, - expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, - expectedIsActive: &updatedIsActive, - expectedDeactivationNote: &updatedDeactivationNote, - expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), + expectedName: cutil.GetPtr("updatedName"), + expectedDescription: cutil.GetPtr("updatedDescription"), + expectedOrg: cutil.GetPtr("updatedOrg"), + expectedInfrastructureProviderID: &updatedIP.ID, + expectedTenantID: &updatedTenant.ID, + expectedVersion: cutil.GetPtr("updatedVersion"), + expectedType: cutil.GetPtr("updatedType"), + expectedImageURL: cutil.GetPtr("updatedImageURL"), + expectedImageSHA: cutil.GetPtr("updatedImageSHA"), + expectedImageAuthType: cutil.GetPtr("updatedImageAuthType"), + expectedImageAuthToken: cutil.GetPtr("updatedImageAuthToken"), + expectedImageDisk: cutil.GetPtr("updatedImageDisk"), + expectedRootFsID: cutil.GetPtr("updatedRootFsID"), + expectedRootFsLabel: cutil.GetPtr("updatedRootFsLabel"), + expectedIpxeScript: cutil.GetPtr("updatedIpxeScript"), + expectedUserData: cutil.GetPtr("updatedUserData"), + expectedIsCloudInit: &updatedIsCloudInit, + expectedAllowOverride: &updatedAllowOverride, + expectedEnableBlockStorage: &updatedEnableBlockStorage, + expectPhoneHomeEnabled: &updatedPhoneHomeEnabled, + expectedIsActive: &updatedIsActive, + expectedDeactivationNote: &updatedDeactivationNote, + expectedStatus: cutil.GetPtr(OperatingSystemStatusProvisioning), }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { input := OperatingSystemUpdateInput{ - OperatingSystemId: tc.os.ID, - Name: tc.paramName, - Description: tc.paramDescription, - Org: tc.paramOrg, - InfrastructureProviderID: tc.paramInfrastructureProviderID, - TenantID: tc.paramTenantID, - ControllerOperatingSystemID: tc.paramControllerOperatingSystemID, - Version: tc.paramVersion, - OsType: tc.paramType, - ImageURL: tc.paramImageURL, - ImageSHA: tc.paramImageSHA, - ImageAuthType: tc.paramImageAuthType, - ImageAuthToken: tc.paramImageAuthToken, - ImageDisk: tc.paramImageDisk, - RootFsId: tc.paramRootFsID, - RootFsLabel: tc.paramRootFsLabel, - IpxeScript: tc.paramIpxeScript, - UserData: tc.paramUserData, - IsCloudInit: tc.paramIsCloudInit, - AllowOverride: tc.paramAllowOverride, - EnableBlockStorage: tc.paramEnableBlockStorage, - PhoneHomeEnabled: tc.paramPhoneHomeEnabled, - IsActive: tc.paramIsActive, - DeactivationNote: tc.paramDeactivationNote, - Status: tc.paramStatus, + OperatingSystemId: tc.os.ID, + Name: tc.paramName, + Description: tc.paramDescription, + Org: tc.paramOrg, + InfrastructureProviderID: tc.paramInfrastructureProviderID, + TenantID: tc.paramTenantID, + Version: tc.paramVersion, + OsType: tc.paramType, + ImageURL: tc.paramImageURL, + ImageSHA: tc.paramImageSHA, + ImageAuthType: tc.paramImageAuthType, + ImageAuthToken: tc.paramImageAuthToken, + ImageDisk: tc.paramImageDisk, + RootFsId: tc.paramRootFsID, + RootFsLabel: tc.paramRootFsLabel, + IpxeScript: tc.paramIpxeScript, + UserData: tc.paramUserData, + IsCloudInit: tc.paramIsCloudInit, + AllowOverride: tc.paramAllowOverride, + EnableBlockStorage: tc.paramEnableBlockStorage, + PhoneHomeEnabled: tc.paramPhoneHomeEnabled, + IsActive: tc.paramIsActive, + DeactivationNote: tc.paramDeactivationNote, + Status: tc.paramStatus, } got, err := ossd.Update(ctx, nil, input) assert.Nil(t, err) @@ -1442,10 +1348,6 @@ func TestOperatingSystemSQLDAO_Update(t *testing.T) { if tc.expectedTenantID != nil { assert.Equal(t, *tc.expectedTenantID, *got.TenantID) } - assert.Equal(t, tc.expectedControllerOperatingSystemID == nil, got.ControllerOperatingSystemID == nil) - if tc.expectedControllerOperatingSystemID != nil { - assert.Equal(t, *tc.expectedControllerOperatingSystemID, *got.ControllerOperatingSystemID) - } assert.Equal(t, tc.expectedVersion == nil, got.Version == nil) if tc.expectedVersion != nil { assert.Equal(t, *tc.expectedVersion, *got.Version) @@ -1522,80 +1424,76 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { tenant2 := testOperatingSystemBuildTenant(t, dbSession, "testTenant2") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() os1tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "image", - ImageURL: cutil.GetPtr("imageURL"), - ImageSHA: cutil.GetPtr("imageSHA"), - ImageAuthType: cutil.GetPtr("imageAuthType"), - ImageAuthToken: cutil.GetPtr("imageAuthToken"), - ImageDisk: cutil.GetPtr("imageDisk"), - RootFsId: cutil.GetPtr("rootFsId"), - RootFsLabel: cutil.GetPtr("rootFsLabel"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: cutil.GetPtr("version"), + OsType: "image", + ImageURL: cutil.GetPtr("imageURL"), + ImageSHA: cutil.GetPtr("imageSHA"), + ImageAuthType: cutil.GetPtr("imageAuthType"), + ImageAuthToken: cutil.GetPtr("imageAuthToken"), + ImageDisk: cutil.GetPtr("imageDisk"), + RootFsId: cutil.GetPtr("rootFsId"), + RootFsLabel: cutil.GetPtr("rootFsLabel"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant1) os2tenant1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os2tenant1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant1.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "ipxe", - ImageURL: cutil.GetPtr("imageURL"), - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os2tenant1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant1.ID, + Version: cutil.GetPtr("version"), + OsType: "ipxe", + ImageURL: cutil.GetPtr("imageURL"), + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os2tenant1) os1tenant2, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant2.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "image", - ImageURL: cutil.GetPtr("imageURL"), - ImageSHA: cutil.GetPtr("imageSHA"), - ImageAuthType: cutil.GetPtr("imageAuthType"), - ImageAuthToken: cutil.GetPtr("imageAuthToken"), - ImageDisk: cutil.GetPtr("imageDisk"), - RootFsId: cutil.GetPtr("rootFsId"), - RootFsLabel: cutil.GetPtr("rootFsLabel"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant2.ID, + Version: cutil.GetPtr("version"), + OsType: "image", + ImageURL: cutil.GetPtr("imageURL"), + ImageSHA: cutil.GetPtr("imageSHA"), + ImageAuthType: cutil.GetPtr("imageAuthType"), + ImageAuthToken: cutil.GetPtr("imageAuthToken"), + ImageDisk: cutil.GetPtr("imageDisk"), + RootFsId: cutil.GetPtr("rootFsId"), + RootFsLabel: cutil.GetPtr("rootFsLabel"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1tenant2) @@ -1604,39 +1502,37 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { _, _, ctx = testCommonTraceProviderSetup(t, ctx) tests := []struct { - desc string - os *OperatingSystem - paramDescription bool - paramInfrastructureProviderID bool - paramTenantID bool - paramControllerOperatingSystemID bool - paramVersion bool - paramImageURL bool - paramImageSHA bool - paramImageAuthType bool - paramImageAuthToken bool - paramImageDisk bool - paramRootFsID bool - paramRootFsLabel bool - paramIpxeScript bool - paramUserData bool - - expectedDescription *string - expectedInfrastructureProviderID *uuid.UUID - expectedTenantID *uuid.UUID - expectedControllerOperatingSystemID *uuid.UUID - expectedVersion *string - expectedImageURL *string - expectedImageSHA *string - expectedImageAuthType *string - expectedImageAuthToken *string - expectedImageDisk *string - expectedRootFsID *string - expectedRootFsLabel *string - expectedIpxeScript *string - expectedUserData *string - expectedUpdate bool - verifyChildSpanner bool + desc string + os *OperatingSystem + paramDescription bool + paramInfrastructureProviderID bool + paramTenantID bool + paramVersion bool + paramImageURL bool + paramImageSHA bool + paramImageAuthType bool + paramImageAuthToken bool + paramImageDisk bool + paramRootFsID bool + paramRootFsLabel bool + paramIpxeScript bool + paramUserData bool + + expectedDescription *string + expectedInfrastructureProviderID *uuid.UUID + expectedTenantID *uuid.UUID + expectedVersion *string + expectedImageURL *string + expectedImageSHA *string + expectedImageAuthType *string + expectedImageAuthToken *string + expectedImageDisk *string + expectedRootFsID *string + expectedRootFsLabel *string + expectedIpxeScript *string + expectedUserData *string + expectedUpdate bool + verifyChildSpanner bool }{ { desc: "can clear description", @@ -1644,22 +1540,21 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramDescription: true, - expectedDescription: nil, - expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, - expectedTenantID: os1tenant1.TenantID, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, - verifyChildSpanner: true, + expectedDescription: nil, + expectedInfrastructureProviderID: os1tenant1.InfrastructureProviderID, + expectedTenantID: os1tenant1.TenantID, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, + verifyChildSpanner: true, }, { desc: "can clear InfrastructureProviderID", @@ -1667,21 +1562,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramInfrastructureProviderID: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: os1tenant1.TenantID, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: os1tenant1.TenantID, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear TenantID", @@ -1689,43 +1583,39 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramTenantID: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: os1tenant1.ControllerOperatingSystemID, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, - }, - { - desc: "can clear ControllerOperatingSystemID", + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, + }, + { + desc: "can run clear with no flags set", os: os1tenant1, - paramControllerOperatingSystemID: true, - - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: os1tenant1.Version, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: os1tenant1.Version, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear version", @@ -1733,21 +1623,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramVersion: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: os1tenant1.ImageURL, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: os1tenant1.ImageURL, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageURL", @@ -1755,21 +1644,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageURL: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: os1tenant1.ImageSHA, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: os1tenant1.ImageSHA, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageSHA", @@ -1777,21 +1665,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageSHA: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: os1tenant1.ImageAuthType, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: os1tenant1.ImageAuthType, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageAuthType", @@ -1799,21 +1686,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageAuthType: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: os1tenant1.ImageAuthToken, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: os1tenant1.ImageAuthToken, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageAuthToken", @@ -1821,21 +1707,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageAuthToken: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: os1tenant1.ImageDisk, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: os1tenant1.ImageDisk, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear ImageDisk", @@ -1843,21 +1728,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramImageDisk: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: os1tenant1.RootFsID, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: os1tenant1.RootFsID, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear RootFsId", @@ -1865,21 +1749,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramRootFsID: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: os1tenant1.RootFsLabel, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: os1tenant1.RootFsLabel, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear RootFsLabel", @@ -1887,21 +1770,20 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramRootFsLabel: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: nil, - expectedIpxeScript: os1tenant1.IpxeScript, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: nil, + expectedIpxeScript: os1tenant1.IpxeScript, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear IpxeScript", @@ -1909,15 +1791,14 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramIpxeScript: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedIpxeScript: nil, - expectedUserData: os1tenant1.UserData, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedIpxeScript: nil, + expectedUserData: os1tenant1.UserData, + expectedUpdate: true, }, { desc: "can clear UserData", @@ -1925,96 +1806,91 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { paramUserData: true, - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: nil, - expectedIpxeScript: nil, - expectedUserData: nil, - expectedUpdate: true, + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: nil, + expectedIpxeScript: nil, + expectedUserData: nil, + expectedUpdate: true, }, { desc: "can clear multiple fields at once", os: os1tenant2, - paramDescription: true, - paramInfrastructureProviderID: true, - paramTenantID: true, - paramControllerOperatingSystemID: true, - paramVersion: true, - paramImageURL: true, - paramImageSHA: true, - paramImageAuthType: true, - paramImageAuthToken: true, - paramImageDisk: true, - paramRootFsID: true, - paramRootFsLabel: true, - paramIpxeScript: true, - paramUserData: true, - - expectedDescription: nil, - expectedInfrastructureProviderID: nil, - expectedTenantID: nil, - expectedControllerOperatingSystemID: nil, - expectedVersion: nil, - expectedImageURL: nil, - expectedImageSHA: nil, - expectedImageAuthType: nil, - expectedImageAuthToken: nil, - expectedImageDisk: nil, - expectedRootFsID: nil, - expectedRootFsLabel: nil, - expectedIpxeScript: nil, - expectedUserData: nil, - expectedUpdate: true, + paramDescription: true, + paramInfrastructureProviderID: true, + paramTenantID: true, + paramVersion: true, + paramImageURL: true, + paramImageSHA: true, + paramImageAuthType: true, + paramImageAuthToken: true, + paramImageDisk: true, + paramRootFsID: true, + paramRootFsLabel: true, + paramIpxeScript: true, + paramUserData: true, + + expectedDescription: nil, + expectedInfrastructureProviderID: nil, + expectedTenantID: nil, + expectedVersion: nil, + expectedImageURL: nil, + expectedImageSHA: nil, + expectedImageAuthType: nil, + expectedImageAuthToken: nil, + expectedImageDisk: nil, + expectedRootFsID: nil, + expectedRootFsLabel: nil, + expectedIpxeScript: nil, + expectedUserData: nil, + expectedUpdate: true, }, { desc: "nop when no cleared fields are specified", os: os2tenant1, - expectedDescription: os2tenant1.Description, - expectedInfrastructureProviderID: os2tenant1.InfrastructureProviderID, - expectedTenantID: os2tenant1.TenantID, - expectedControllerOperatingSystemID: os2tenant1.ControllerOperatingSystemID, - expectedVersion: os2tenant1.Version, - expectedImageURL: os2tenant1.ImageURL, - expectedImageSHA: os2tenant1.ImageSHA, - expectedImageAuthType: os2tenant1.ImageAuthType, - expectedImageAuthToken: os2tenant1.ImageAuthToken, - expectedImageDisk: os2tenant1.ImageDisk, - expectedRootFsID: os2tenant1.RootFsID, - expectedRootFsLabel: os2tenant1.RootFsLabel, - expectedIpxeScript: os2tenant1.IpxeScript, - expectedUserData: os2tenant1.UserData, - expectedUpdate: false, + expectedDescription: os2tenant1.Description, + expectedInfrastructureProviderID: os2tenant1.InfrastructureProviderID, + expectedTenantID: os2tenant1.TenantID, + expectedVersion: os2tenant1.Version, + expectedImageURL: os2tenant1.ImageURL, + expectedImageSHA: os2tenant1.ImageSHA, + expectedImageAuthType: os2tenant1.ImageAuthType, + expectedImageAuthToken: os2tenant1.ImageAuthToken, + expectedImageDisk: os2tenant1.ImageDisk, + expectedRootFsID: os2tenant1.RootFsID, + expectedRootFsLabel: os2tenant1.RootFsLabel, + expectedIpxeScript: os2tenant1.IpxeScript, + expectedUserData: os2tenant1.UserData, + expectedUpdate: false, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { input := OperatingSystemClearInput{ - OperatingSystemId: tc.os.ID, - Description: tc.paramDescription, - InfrastructureProviderID: tc.paramInfrastructureProviderID, - TenantID: tc.paramTenantID, - ControllerOperatingSystemID: tc.paramControllerOperatingSystemID, - Version: tc.paramVersion, - ImageURL: tc.paramImageURL, - ImageSHA: tc.paramImageSHA, - ImageAuthType: tc.paramImageAuthType, - ImageAuthToken: tc.paramImageAuthToken, - ImageDisk: tc.paramImageDisk, - RootFsId: tc.paramRootFsID, - RootFsLabel: tc.paramRootFsLabel, - IpxeScript: tc.paramIpxeScript, - UserData: tc.paramUserData, + OperatingSystemId: tc.os.ID, + Description: tc.paramDescription, + InfrastructureProviderID: tc.paramInfrastructureProviderID, + TenantID: tc.paramTenantID, + Version: tc.paramVersion, + ImageURL: tc.paramImageURL, + ImageSHA: tc.paramImageSHA, + ImageAuthType: tc.paramImageAuthType, + ImageAuthToken: tc.paramImageAuthToken, + ImageDisk: tc.paramImageDisk, + RootFsId: tc.paramRootFsID, + RootFsLabel: tc.paramRootFsLabel, + IpxeScript: tc.paramIpxeScript, + UserData: tc.paramUserData, } tmp, err := ossd.Clear(ctx, nil, input) assert.Nil(t, err) @@ -2031,10 +1907,6 @@ func TestOperatingSystemSQLDAO_Clear(t *testing.T) { if tc.expectedTenantID != nil { assert.Equal(t, *tc.expectedTenantID, *tmp.TenantID) } - assert.Equal(t, tc.expectedControllerOperatingSystemID == nil, tmp.ControllerOperatingSystemID == nil) - if tc.expectedControllerOperatingSystemID != nil { - assert.Equal(t, *tc.expectedControllerOperatingSystemID, *tmp.ControllerOperatingSystemID) - } assert.Equal(t, tc.expectedVersion == nil, tmp.Version == nil) if tc.expectedVersion != nil { assert.Equal(t, *tc.expectedVersion, *tmp.Version) @@ -2098,26 +1970,24 @@ func TestOperatingSystemSQLDAO_Delete(t *testing.T) { tenant := testOperatingSystemBuildTenant(t, dbSession, "testTenant") user := testOperatingSystemBuildUser(t, dbSession, "testUser") ossd := NewOperatingSystemDAO(dbSession) - dummyUUID := uuid.New() os1, err := ossd.Create( ctx, nil, OperatingSystemCreateInput{ - Name: "os1", - Description: cutil.GetPtr("description"), - Org: "testOrg", - InfrastructureProviderID: &ip.ID, - TenantID: &tenant.ID, - ControllerOperatingSystemID: &dummyUUID, - Version: cutil.GetPtr("version"), - OsType: "ipxe", - ImageURL: cutil.GetPtr("imageURL"), - IpxeScript: cutil.GetPtr("ipxeScript"), - UserData: cutil.GetPtr("userData"), - IsCloudInit: true, - AllowOverride: true, - EnableBlockStorage: true, - PhoneHomeEnabled: true, - Status: OperatingSystemStatusPending, - CreatedBy: user.ID, + Name: "os1", + Description: cutil.GetPtr("description"), + Org: "testOrg", + InfrastructureProviderID: &ip.ID, + TenantID: &tenant.ID, + Version: cutil.GetPtr("version"), + OsType: "ipxe", + ImageURL: cutil.GetPtr("imageURL"), + IpxeScript: cutil.GetPtr("ipxeScript"), + UserData: cutil.GetPtr("userData"), + IsCloudInit: true, + AllowOverride: true, + EnableBlockStorage: true, + PhoneHomeEnabled: true, + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, }) assert.Nil(t, err) assert.NotNil(t, os1) diff --git a/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go b/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go index 1ce670c160..3c6f5968de 100644 --- a/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go +++ b/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go @@ -16,6 +16,8 @@ import ( "github.com/uptrace/bun" stracer "github.com/NVIDIA/infra-controller/rest-api/db/pkg/tracer" + + ws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" ) var ( @@ -56,6 +58,14 @@ var ( OperatingSystemSiteAssociationStatusError: true, OperatingSystemSiteAssociationStatusDeleting: true, } + + OperatingSystemSiteAssociationStatusFromProtoMap = map[ws.TenantState]string{ + ws.TenantState_PROVISIONING: OperatingSystemSiteAssociationStatusSyncing, + ws.TenantState_READY: OperatingSystemSiteAssociationStatusSynced, + ws.TenantState_CONFIGURING: OperatingSystemSiteAssociationStatusSyncing, + ws.TenantState_TERMINATING: OperatingSystemSiteAssociationStatusDeleting, + ws.TenantState_FAILED: OperatingSystemSiteAssociationStatusError, + } ) // OperatingSystemSiteAssociation associates an OperatingSystem with different Sites @@ -69,6 +79,7 @@ type OperatingSystemSiteAssociation struct { Site *Site `bun:"rel:belongs-to,join:site_id=id"` Version *string `bun:"version"` Status string `bun:"status,notnull"` + ControllerState *string `bun:"controller_state"` IsMissingOnSite bool `bun:"is_missing_on_site,notnull"` Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` @@ -82,6 +93,7 @@ type OperatingSystemSiteAssociationCreateInput struct { SiteID uuid.UUID Version *string Status string + ControllerState *string CreatedBy uuid.UUID } @@ -92,6 +104,7 @@ type OperatingSystemSiteAssociationUpdateInput struct { SiteID *uuid.UUID Version *string Status *string + ControllerState *string IsMissingOnSite *bool } @@ -132,7 +145,7 @@ type OperatingSystemSiteAssociationDAO interface { // GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) // - GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, OperatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) + GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, operatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) // GetAll(ctx context.Context, tx *db.Tx, filter OperatingSystemSiteAssociationFilterInput, page paginator.PageInput, includeRelations []string) ([]OperatingSystemSiteAssociation, int, error) // @@ -167,6 +180,7 @@ func (ossasd OperatingSystemSiteAssociationSQLDAO) Create( SiteID: input.SiteID, Version: input.Version, Status: input.Status, + ControllerState: input.ControllerState, CreatedBy: input.CreatedBy, } @@ -215,19 +229,19 @@ func (ossasd OperatingSystemSiteAssociationSQLDAO) GetByID(ctx context.Context, // GetByOperatingSystemIDAndSiteID returns an OperatingSystemSiteAssociation by OperatingSystemID and SiteID // returns db.ErrDoesNotExist error if the record is not found -func (ossasd OperatingSystemSiteAssociationSQLDAO) GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, OperatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) { +func (ossasd OperatingSystemSiteAssociationSQLDAO) GetByOperatingSystemIDAndSiteID(ctx context.Context, tx *db.Tx, operatingSystemID uuid.UUID, siteID uuid.UUID, includeRelations []string) (*OperatingSystemSiteAssociation, error) { // Create a child span and set the attributes for current request ctx, OperatingSystemSiteAssociationDAOSpan := ossasd.tracerSpan.CreateChildInCurrentContext(ctx, "OperatingSystemSiteAssociationDAO.GetByOperatingSystemIDAndSiteID") if OperatingSystemSiteAssociationDAOSpan != nil { defer OperatingSystemSiteAssociationDAOSpan.End() - ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "operating_system_id", OperatingSystemID.String()) + ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "operating_system_id", operatingSystemID.String()) ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "site_id", siteID.String()) } ossa := &OperatingSystemSiteAssociation{} - query := db.GetIDB(tx, ossasd.dbSession).NewSelect().Model(ossa).Where("ossa.operating_system_id = ?", OperatingSystemID.String()).Where("ossa.site_id = ?", siteID.String()) + query := db.GetIDB(tx, ossasd.dbSession).NewSelect().Model(ossa).Where("ossa.operating_system_id = ?", operatingSystemID.String()).Where("ossa.site_id = ?", siteID.String()) for _, relation := range includeRelations { query = query.Relation(relation) @@ -399,6 +413,11 @@ func (ossasd OperatingSystemSiteAssociationSQLDAO) Update( updatedFields = append(updatedFields, "is_missing_on_site") ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "is_missing_on_site", *input.IsMissingOnSite) } + if input.ControllerState != nil { + ossa.ControllerState = input.ControllerState + updatedFields = append(updatedFields, "controller_state") + ossasd.tracerSpan.SetAttribute(OperatingSystemSiteAssociationDAOSpan, "controller_state", *input.ControllerState) + } if len(updatedFields) > 0 { updatedFields = append(updatedFields, "updated") diff --git a/rest-api/db/pkg/migrations/20260615120000_ipxe_os_and_templates.go b/rest-api/db/pkg/migrations/20260615120000_ipxe_os_and_templates.go new file mode 100644 index 0000000000..0098ae930c --- /dev/null +++ b/rest-api/db/pkg/migrations/20260615120000_ipxe_os_and_templates.go @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/uptrace/bun" +) + +// 20260615120000_ipxe_os_and_templates +// +// Introduces the iPXE Template + OS Template support in REST: +// - Global `ipxe_template` table, keyed by the stable template UUID assigned +// by nico-core (the same UUID is used on both sides). +// - `ipxe_template_site_association` (ITSA) table tracking which sites +// currently report each global template. +// - iPXE-template related columns on `operating_system` and +// `operating_system_site_association`. +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") + } + + // ── iPXE Template table (global) ───────────────────────────────── + + _, err := tx.NewCreateTable().Model((*model.IpxeTemplate)(nil)).IfNotExists().Exec(ctx) + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE ipxe_template DROP CONSTRAINT IF EXISTS ipxe_template_name_key") + handleError(tx, err) + _, err = tx.Exec("ALTER TABLE ipxe_template ADD CONSTRAINT ipxe_template_name_key UNIQUE (name)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_name_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_name_idx ON ipxe_template(name)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_scope_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_scope_idx ON ipxe_template(scope)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_created_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_created_idx ON ipxe_template(created)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS ipxe_template_updated_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX ipxe_template_updated_idx ON ipxe_template(updated)") + handleError(tx, err) + + // ── iPXE Template ↔ Site association ───────────────────────────── + + _, err = tx.NewCreateTable().Model((*model.IpxeTemplateSiteAssociation)(nil)).IfNotExists().Exec(ctx) + handleError(tx, err) + + _, err = tx.Exec(` + ALTER TABLE ipxe_template_site_association + DROP CONSTRAINT IF EXISTS ipxe_template_site_association_template_id_site_id_key + `) + handleError(tx, err) + _, err = tx.Exec(` + ALTER TABLE ipxe_template_site_association + ADD CONSTRAINT ipxe_template_site_association_template_id_site_id_key + UNIQUE (ipxe_template_id, site_id) + `) + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS itsa_ipxe_template_id_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX itsa_ipxe_template_id_idx ON ipxe_template_site_association(ipxe_template_id)") + handleError(tx, err) + + _, err = tx.Exec("DROP INDEX IF EXISTS itsa_site_id_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX itsa_site_id_idx ON ipxe_template_site_association(site_id)") + handleError(tx, err) + + // ── Operating System: iPXE definition columns ──────────────────── + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_id TEXT NULL") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_parameters JSONB NULL") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_artifacts JSONB NULL") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_template_definition_hash TEXT NULL") + handleError(tx, err) + + // controller_operating_system_id is no longer needed: the primary key is the same on + // both sides, so drop the column and its index if they still exist. + _, err = tx.Exec("DROP INDEX IF EXISTS operating_system_controller_os_id_idx") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system DROP COLUMN IF EXISTS controller_operating_system_id") + handleError(tx, err) + + // ── Operating System: scope column ─────────────────────────────── + + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_os_scope TEXT NULL") + handleError(tx, err) + + // ── Operating System Site Association: controller state ────────── + + _, err = tx.Exec("ALTER TABLE operating_system_site_association ADD COLUMN IF NOT EXISTS controller_state TEXT NULL") + handleError(tx, err) + + // ── Backfill ipxe_os_scope for existing iPXE-type OS records ──── + // + // Tenant-owned raw iPXE → Global (preserves legacy behavior: tenant + // can use it for any Instance at any accessible site). + // Provider-owned iPXE (from nico-core inventory) → Local (single + // site, bidirectional sync). + // Image-type OS entries are left as NULL since scope does not apply. + + _, err = tx.Exec(` + UPDATE operating_system + SET ipxe_os_scope = 'Global' + WHERE ipxe_os_scope IS NULL + AND type = 'iPXE' + AND tenant_id IS NOT NULL + AND deleted IS NULL + `) + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [up migration] ") + return nil + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] No action taken") + return nil + }) +} diff --git a/rest-api/docs/index.html b/rest-api/docs/index.html index 02c4196f20..912cdd9fdb 100644 --- a/rest-api/docs/index.html +++ b/rest-api/docs/index.html @@ -3,7 +3,7 @@
-