From b200f9e179edfb58aee5d768d9f8ae20d9afb4e6 Mon Sep 17 00:00:00 2001 From: Kyle Felter Date: Tue, 23 Jun 2026 14:39:11 -0500 Subject: [PATCH 1/5] feat: Add Templated iPXE operating system data model and schema Signed-off-by: Kyle Felter --- rest-api/db/pkg/db/model/ipxetemplate.go | 294 ++++++++++++++++++ rest-api/db/pkg/db/model/ipxetemplate_test.go | 277 +++++++++++++++++ .../db/model/ipxetemplatesiteassociation.go | 270 ++++++++++++++++ .../model/ipxetemplatesiteassociation_test.go | 143 +++++++++ rest-api/db/pkg/db/model/operatingsystem.go | 275 ++++++++++++++-- .../pkg/db/model/operatingsystem_ipxe_test.go | 151 +++++++++ .../model/operatingsystemsiteassociation.go | 20 +- .../20260623150000_ipxe_os_and_templates.go | 152 +++++++++ 8 files changed, 1543 insertions(+), 39 deletions(-) create mode 100644 rest-api/db/pkg/db/model/ipxetemplate.go create mode 100644 rest-api/db/pkg/db/model/ipxetemplate_test.go create mode 100644 rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go create mode 100644 rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go create mode 100644 rest-api/db/pkg/db/model/operatingsystem_ipxe_test.go create mode 100644 rest-api/db/pkg/migrations/20260623150000_ipxe_os_and_templates.go 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..a6da4d4424 --- /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 20260623150000_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..2a64cb8f8d 100644 --- a/rest-api/db/pkg/db/model/operatingsystem.go +++ b/rest-api/db/pkg/db/model/operatingsystem.go @@ -37,11 +37,20 @@ const ( // OperatingSystemRelationName is the relation name for the OperatingSystem model OperatingSystemRelationName = "OperatingSystem" - // OperatingSystemTypeIPXE is the ipxe based OperatingSystem type + // 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" + // OperatingSystemScopeLocal means single site, bidirectional sync (provider-owned OS from nico-core). + OperatingSystemScopeLocal = "Local" + // OperatingSystemScopeLimited means carbide-rest is the source of truth for a fixed list of sites. + OperatingSystemScopeLimited = "Limited" + // OperatingSystemScopeGlobal means carbide-rest is the source of truth for all owner sites. + OperatingSystemScopeGlobal = "Global" + // OperatingSystemOrderByDefault default field to be used for ordering when none specified OperatingSystemOrderByDefault = "created" @@ -49,6 +58,15 @@ const ( OperatingSystemAuthTypeBasic = "Basic" // OperatingSystemAuthTypeBearer is the bearer image auth type OperatingSystemAuthTypeBearer = "Bearer" + + // OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded caches the artifact locally when possible. + OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded = "CacheAsNeeded" + // OperatingSystemIpxeArtifactCacheStrategyLocalOnly marks the artifact URL as usable only on-site. + OperatingSystemIpxeArtifactCacheStrategyLocalOnly = "LocalOnly" + // OperatingSystemIpxeArtifactCacheStrategyCachedOnly requires the artifact to be cached locally. + OperatingSystemIpxeArtifactCacheStrategyCachedOnly = "CachedOnly" + // OperatingSystemIpxeArtifactCacheStrategyRemoteOnly always fetches the artifact from the remote URL. + OperatingSystemIpxeArtifactCacheStrategyRemoteOnly = "RemoteOnly" ) var ( @@ -71,11 +89,109 @@ 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, + } + + // OperatingSystemIpxeArtifactCacheStrategyFromProtoMap maps proto cache strategies to their model string values. + OperatingSystemIpxeArtifactCacheStrategyFromProtoMap = map[cwssaws.IpxeTemplateArtifactCacheStrategy]string{ + cwssaws.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED: OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded, + cwssaws.IpxeTemplateArtifactCacheStrategy_LOCAL_ONLY: OperatingSystemIpxeArtifactCacheStrategyLocalOnly, + cwssaws.IpxeTemplateArtifactCacheStrategy_CACHED_ONLY: OperatingSystemIpxeArtifactCacheStrategyCachedOnly, + cwssaws.IpxeTemplateArtifactCacheStrategy_REMOTE_ONLY: OperatingSystemIpxeArtifactCacheStrategyRemoteOnly, + } + + // OperatingSystemIpxeArtifactCacheStrategyToProtoMap maps model cache strategy strings to their proto values. + OperatingSystemIpxeArtifactCacheStrategyToProtoMap = map[string]cwssaws.IpxeTemplateArtifactCacheStrategy{ + OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded: cwssaws.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED, + OperatingSystemIpxeArtifactCacheStrategyLocalOnly: cwssaws.IpxeTemplateArtifactCacheStrategy_LOCAL_ONLY, + OperatingSystemIpxeArtifactCacheStrategyCachedOnly: cwssaws.IpxeTemplateArtifactCacheStrategy_CACHED_ONLY, + OperatingSystemIpxeArtifactCacheStrategyRemoteOnly: cwssaws.IpxeTemplateArtifactCacheStrategy_REMOTE_ONLY, } ) +// 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 with nico-core. +type OperatingSystemIpxeParameter struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// FromProto fills the receiver from a proto IpxeTemplateParameter. A nil proto resets the receiver. +func (osip *OperatingSystemIpxeParameter) FromProto(protoParam *cwssaws.IpxeTemplateParameter) { + if protoParam == nil { + *osip = OperatingSystemIpxeParameter{} + return + } + osip.Name = protoParam.Name + osip.Value = protoParam.Value +} + +// ToProto converts the receiver to a proto IpxeTemplateParameter. +func (osip *OperatingSystemIpxeParameter) ToProto() *cwssaws.IpxeTemplateParameter { + return &cwssaws.IpxeTemplateParameter{ + Name: osip.Name, + Value: osip.Value, + } +} + +// OperatingSystemIpxeArtifact holds a single iPXE artifact descriptor (stored as JSONB). +// These are only populated for iPXE-based OS definitions synced with nico-core. +// +// 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, so there is no meaningful global value for it on the rest side. +// The push path must therefore never emit cached_url to core (preserving per-site +// values), and the inbound (pull) path must never store cached_url on the global 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 fills the receiver from a proto IpxeTemplateArtifact. A nil proto resets the receiver. +// The proto's cached_url field is intentionally ignored; see the type doc. +func (osia *OperatingSystemIpxeArtifact) FromProto(protoArtifact *cwssaws.IpxeTemplateArtifact) { + if protoArtifact == nil { + *osia = OperatingSystemIpxeArtifact{} + return + } + 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 +} + +// ToProto converts the receiver to a proto IpxeTemplateArtifact. cached_url is always left +// nil so the rest side never overwrites the per-site value managed by nico-core. +func (osia *OperatingSystemIpxeArtifact) ToProto() *cwssaws.IpxeTemplateArtifact { + return &cwssaws.IpxeTemplateArtifact{ + Name: osia.Name, + Url: osia.URL, + Sha: osia.SHA, + AuthType: osia.AuthType, + AuthToken: osia.AuthToken, + CacheStrategy: OperatingSystemIpxeArtifactCacheStrategyToProtoMap[osia.CacheStrategy], + CachedUrl: nil, + } +} + // OperatingSystem describes the attributes of the operating system // that can be used on instances type OperatingSystem struct { @@ -100,18 +216,28 @@ type OperatingSystem struct { 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"` + // iPXE template fields, populated for Templated iPXE OS definitions synced with nico-core. + 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 for + // iPXE OS definitions: "Local" is bidirectional/provider-owned from nico-core, while + // "Global" and "Limited" make carbide-rest the source of truth. nil for Image-type OS; + // legacy iPXE rows with nil scope are treated as "Local". + IpxeOsScope *string `bun:"ipxe_os_scope"` + UserData *string `bun:"user_data"` + 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"` } // GetSiteID returns the OperatingSystem ID to use when communicating @@ -182,13 +308,19 @@ type OperatingSystemCreateInput struct { RootFsId *string RootFsLabel *string IpxeScript *string - UserData *string - IsCloudInit bool - AllowOverride bool - EnableBlockStorage bool - PhoneHomeEnabled bool - Status string - CreatedBy uuid.UUID + // iPXE template definition fields (for nico-core synced iPXE OS definitions) + IpxeTemplateId *string + IpxeTemplateParameters []OperatingSystemIpxeParameter + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact + IpxeOSHash *string + IpxeOsScope *string + UserData *string + IsCloudInit bool + AllowOverride bool + EnableBlockStorage bool + PhoneHomeEnabled bool + Status string + CreatedBy uuid.UUID } // OperatingSystemUpdateInput input parameters for Update method @@ -210,14 +342,20 @@ type OperatingSystemUpdateInput struct { RootFsId *string RootFsLabel *string IpxeScript *string - UserData *string - IsCloudInit *bool - AllowOverride *bool - EnableBlockStorage *bool - PhoneHomeEnabled *bool - IsActive *bool - DeactivationNote *string - Status *string + // iPXE template definition fields (for nico-core synced iPXE OS definitions) + IpxeTemplateId *string + IpxeTemplateParameters *[]OperatingSystemIpxeParameter + IpxeTemplateArtifacts *[]OperatingSystemIpxeArtifact + IpxeOSHash *string + Scope *string + UserData *string + IsCloudInit *bool + AllowOverride *bool + EnableBlockStorage *bool + PhoneHomeEnabled *bool + IsActive *bool + DeactivationNote *string + Status *string } // OperatingSystemClearInput input parameters for Clear method @@ -238,6 +376,12 @@ type OperatingSystemClearInput struct { IpxeScript bool UserData bool DeactivationNote bool + // iPXE template definition fields (for nico-core synced iPXE OS definitions) + IpxeTemplateId bool + IpxeTemplateParameters bool + IpxeTemplateArtifacts bool + IpxeOSHash bool + Scope bool } type OperatingSystemFilterInput struct { @@ -251,6 +395,10 @@ type OperatingSystemFilterInput struct { SearchQuery *string OperatingSystemIds []uuid.UUID IsActive *bool + // Scopes filters iPXE OS definitions by their scope (e.g. "Global", "Limited", "Local"). + Scopes []string + // IncludeDeleted includes soft-deleted records (used by inventory sync to detect deletions). + IncludeDeleted bool } var _ bun.BeforeAppendModelHook = (*OperatingSystem)(nil) @@ -336,10 +484,15 @@ func (ossd OperatingSystemSQLDAO) Create(ctx context.Context, tx *db.Tx, input O 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) @@ -455,6 +608,20 @@ func (ossd OperatingSystemSQLDAO) GetAll(ctx context.Context, tx *db.Tx, filter query = query.Where("os.is_active = ?", *filter.IsActive) ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "is_active", *filter.IsActive) } + if filter.Scopes != nil { + // Scope only applies to iPXE OS rows; restrict the match to iPXE types so Image rows + // (which have a NULL scope) are not coerced to "Local" by the COALESCE. + query = query.Where( + "os.type IN (?) AND COALESCE(os.ipxe_os_scope, ?) IN (?)", + bun.In([]string{OperatingSystemTypeIPXE, OperatingSystemTypeTemplatedIPXE}), + OperatingSystemScopeLocal, + bun.In(filter.Scopes), + ) + ossd.tracerSpan.SetAttribute(operatingSystemSQLDAOSpan, "scopes", filter.Scopes) + } + if filter.IncludeDeleted { + query = query.WhereAllWithDeleted() + } for _, relation := range includeRelations { query = query.Relation(relation) @@ -616,6 +783,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") @@ -712,6 +899,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_ipxe_test.go b/rest-api/db/pkg/db/model/operatingsystem_ipxe_test.go new file mode 100644 index 0000000000..18b2918659 --- /dev/null +++ b/rest-api/db/pkg/db/model/operatingsystem_ipxe_test.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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/paginator" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestOperatingSystemSQLDAO_TemplatedIPXERoundTrip exercises the iPXE template definition +// columns and the ipxe_os_scope column added for the Templated iPXE OS variant: create, +// read-back of the JSONB parameter/artifact slices, scope update, and scope/iPXE clear. +func TestOperatingSystemSQLDAO_TemplatedIPXERoundTrip(t *testing.T) { + ctx := context.Background() + dbSession := testOperatingSystemInitDB(t) + defer dbSession.Close() + testOperatingSystemSetupSchema(t, dbSession) + + tenant := testOperatingSystemBuildTenant(t, dbSession, "testTenant") + user := testOperatingSystemBuildUser(t, dbSession, "testUser") + + dao := NewOperatingSystemDAO(dbSession) + + templateID := uuid.New().String() + created, err := dao.Create(ctx, nil, OperatingSystemCreateInput{ + Name: "templated-ipxe-os", + Org: "test", + TenantID: &tenant.ID, + OsType: OperatingSystemTypeTemplatedIPXE, + IpxeTemplateId: &templateID, + IpxeTemplateParameters: []OperatingSystemIpxeParameter{ + {Name: "kernel_params", Value: "quiet"}, + }, + IpxeTemplateArtifacts: []OperatingSystemIpxeArtifact{ + { + Name: "kernel", + URL: "https://example.test/kernel", + AuthToken: cutil.GetPtr("secret-token"), + CacheStrategy: OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded, + }, + }, + IpxeOSHash: cutil.GetPtr("hash-1"), + IpxeOsScope: cutil.GetPtr(OperatingSystemScopeGlobal), + Status: OperatingSystemStatusPending, + CreatedBy: user.ID, + }) + require.NoError(t, err) + require.NotNil(t, created) + + got, err := dao.GetByID(ctx, nil, created.ID, nil) + require.NoError(t, err) + assert.Equal(t, OperatingSystemTypeTemplatedIPXE, got.Type) + require.NotNil(t, got.IpxeOsScope) + assert.Equal(t, OperatingSystemScopeGlobal, *got.IpxeOsScope) + require.NotNil(t, got.IpxeTemplateId) + assert.Equal(t, templateID, *got.IpxeTemplateId) + require.NotNil(t, got.IpxeTemplateDefinitionHash) + assert.Equal(t, "hash-1", *got.IpxeTemplateDefinitionHash) + + require.Len(t, got.IpxeTemplateParameters, 1) + assert.Equal(t, "kernel_params", got.IpxeTemplateParameters[0].Name) + assert.Equal(t, "quiet", got.IpxeTemplateParameters[0].Value) + + require.Len(t, got.IpxeTemplateArtifacts, 1) + assert.Equal(t, "kernel", got.IpxeTemplateArtifacts[0].Name) + require.NotNil(t, got.IpxeTemplateArtifacts[0].AuthToken) + assert.Equal(t, "secret-token", *got.IpxeTemplateArtifacts[0].AuthToken) + assert.Equal(t, OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded, got.IpxeTemplateArtifacts[0].CacheStrategy) + + // Update scope and artifacts. + updated, err := dao.Update(ctx, nil, OperatingSystemUpdateInput{ + OperatingSystemId: created.ID, + Scope: cutil.GetPtr(OperatingSystemScopeLimited), + IpxeTemplateArtifacts: &[]OperatingSystemIpxeArtifact{ + {Name: "initrd", URL: "https://example.test/initrd", CacheStrategy: OperatingSystemIpxeArtifactCacheStrategyCachedOnly}, + }, + }) + require.NoError(t, err) + require.NotNil(t, updated.IpxeOsScope) + assert.Equal(t, OperatingSystemScopeLimited, *updated.IpxeOsScope) + require.Len(t, updated.IpxeTemplateArtifacts, 1) + assert.Equal(t, "initrd", updated.IpxeTemplateArtifacts[0].Name) + assert.Equal(t, OperatingSystemIpxeArtifactCacheStrategyCachedOnly, updated.IpxeTemplateArtifacts[0].CacheStrategy) + + // Clear the iPXE definition and scope. + cleared, err := dao.Clear(ctx, nil, OperatingSystemClearInput{ + OperatingSystemId: created.ID, + IpxeTemplateId: true, + IpxeTemplateParameters: true, + IpxeTemplateArtifacts: true, + IpxeOSHash: true, + Scope: true, + }) + require.NoError(t, err) + assert.Nil(t, cleared.IpxeOsScope) + assert.Nil(t, cleared.IpxeTemplateId) + assert.Nil(t, cleared.IpxeTemplateParameters) + assert.Nil(t, cleared.IpxeTemplateArtifacts) + assert.Nil(t, cleared.IpxeTemplateDefinitionHash) +} + +// TestOperatingSystemSQLDAO_ScopeFilter verifies the Scopes filter only matches iPXE OS rows +// and never coerces Image rows (NULL scope) into the "Local" bucket. +func TestOperatingSystemSQLDAO_ScopeFilter(t *testing.T) { + ctx := context.Background() + dbSession := testOperatingSystemInitDB(t) + defer dbSession.Close() + testOperatingSystemSetupSchema(t, dbSession) + + ip := testOperatingSystemBuildInfrastructureProvider(t, dbSession, "testIP") + tenant := testOperatingSystemBuildTenant(t, dbSession, "testTenant") + user := testOperatingSystemBuildUser(t, dbSession, "testUser") + + dao := NewOperatingSystemDAO(dbSession) + + // Global-scoped Templated iPXE OS. + _, err := dao.Create(ctx, nil, OperatingSystemCreateInput{ + Name: "global-ipxe", Org: "test", TenantID: &tenant.ID, + OsType: OperatingSystemTypeTemplatedIPXE, IpxeOsScope: cutil.GetPtr(OperatingSystemScopeGlobal), + Status: OperatingSystemStatusReady, CreatedBy: user.ID, + }) + require.NoError(t, err) + + // Raw iPXE OS with no explicit scope (treated as Local via COALESCE). + _, err = dao.Create(ctx, nil, OperatingSystemCreateInput{ + Name: "local-ipxe", Org: "test", TenantID: &tenant.ID, + OsType: OperatingSystemTypeIPXE, + Status: OperatingSystemStatusReady, CreatedBy: user.ID, + }) + require.NoError(t, err) + + // Image OS: scope does not apply (NULL scope) and must never match a scope filter. + _ = testBuildImageOperatingSystem(t, dbSession, "image-os", cutil.GetPtr("img"), "test", &ip.ID, &tenant.ID, nil, false, OperatingSystemStatusReady, user.ID) + + globalRows, _, err := dao.GetAll(ctx, nil, OperatingSystemFilterInput{Scopes: []string{OperatingSystemScopeGlobal}}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Len(t, globalRows, 1) + assert.Equal(t, "global-ipxe", globalRows[0].Name) + + localRows, _, err := dao.GetAll(ctx, nil, OperatingSystemFilterInput{Scopes: []string{OperatingSystemScopeLocal}}, paginator.PageInput{}, nil) + require.NoError(t, err) + assert.Len(t, localRows, 1) + assert.Equal(t, "local-ipxe", localRows[0].Name) +} diff --git a/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go b/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go index 1ce670c160..3baa56b9bc 100644 --- a/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go +++ b/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go @@ -69,11 +69,13 @@ type OperatingSystemSiteAssociation struct { Site *Site `bun:"rel:belongs-to,join:site_id=id"` Version *string `bun:"version"` Status string `bun:"status,notnull"` - 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"` - Deleted *time.Time `bun:"deleted,soft_delete"` - CreatedBy uuid.UUID `bun:"created_by,type:uuid,notnull"` + // ControllerState mirrors the tenant state reported by nico-core for this OS at this site. + 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"` + Deleted *time.Time `bun:"deleted,soft_delete"` + CreatedBy uuid.UUID `bun:"created_by,type:uuid,notnull"` } // OperatingSystemSiteAssociationCreateInput input parameters for Create method @@ -82,6 +84,7 @@ type OperatingSystemSiteAssociationCreateInput struct { SiteID uuid.UUID Version *string Status string + ControllerState *string CreatedBy uuid.UUID } @@ -92,6 +95,7 @@ type OperatingSystemSiteAssociationUpdateInput struct { SiteID *uuid.UUID Version *string Status *string + ControllerState *string IsMissingOnSite *bool } @@ -167,6 +171,7 @@ func (ossasd OperatingSystemSiteAssociationSQLDAO) Create( SiteID: input.SiteID, Version: input.Version, Status: input.Status, + ControllerState: input.ControllerState, CreatedBy: input.CreatedBy, } @@ -399,6 +404,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/20260623150000_ipxe_os_and_templates.go b/rest-api/db/pkg/migrations/20260623150000_ipxe_os_and_templates.go new file mode 100644 index 0000000000..9a211b54f9 --- /dev/null +++ b/rest-api/db/pkg/migrations/20260623150000_ipxe_os_and_templates.go @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/uptrace/bun" +) + +// 20260623150000_ipxe_os_and_templates +// +// Adds the schema needed for the Templated iPXE Operating System variant and its +// synchronization with nico-core: +// - ipxe_template: global iPXE script templates, keyed by the stable UUID assigned +// by nico-core (the same UUID is used on both sides). +// - ipxe_template_site_association (ITSA): tracks which sites currently report each +// template. +// - operating_system: iPXE template definition columns plus the ipxe_os_scope column +// that controls synchronization direction. +// - operating_system_site_association: controller_state column mirroring the per-site +// tenant state reported by nico-core. +// +// This migration is additive. The legacy controller_operating_system_id column is +// intentionally left in place; consolidating on a shared OS UUID is handled separately +// once all readers have been updated. +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). The unique name constraint and column types + // come from the bun model definition. + _, err := tx.NewCreateTable().Model((*model.IpxeTemplate)(nil)).IfNotExists().Exec(ctx) + handleError(tx, err) + + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS ipxe_template_name_idx ON ipxe_template(name)") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS ipxe_template_scope_idx ON ipxe_template(scope)") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS ipxe_template_created_idx ON ipxe_template(created)") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS ipxe_template_updated_idx ON ipxe_template(updated)") + handleError(tx, err) + + // iPXE Template <-> Site association. Foreign keys are declared on the model. + _, 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("CREATE INDEX IF NOT EXISTS itsa_ipxe_template_id_idx ON ipxe_template_site_association(ipxe_template_id)") + handleError(tx, err) + _, err = tx.Exec("CREATE INDEX IF NOT EXISTS itsa_site_id_idx ON ipxe_template_site_association(site_id)") + handleError(tx, err) + + // Operating System: iPXE template 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) + _, err = tx.Exec("ALTER TABLE operating_system ADD COLUMN IF NOT EXISTS ipxe_os_scope TEXT NULL") + handleError(tx, err) + + // Operating System Site Association: per-site 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. Image rows keep a + // NULL scope since scope does not apply to them. + // tenant-owned iPXE -> Global (carbide-rest is the source of truth) + // provider-owned iPXE -> Local (bidirectional with nico-core) + _, 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) + _, err = tx.Exec(` + UPDATE operating_system + SET ipxe_os_scope = 'Local' + WHERE ipxe_os_scope IS NULL + AND type = 'iPXE' + AND tenant_id IS NULL + AND deleted IS NULL + `) + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [up migration] Added iPXE template tables and Operating System scope/template columns. ") + return nil + }, func(ctx context.Context, db *bun.DB) error { + tx, terr := db.BeginTx(ctx, &sql.TxOptions{}) + if terr != nil { + handlePanic(terr, "failed to begin transaction") + } + + _, err := tx.Exec("ALTER TABLE operating_system_site_association DROP COLUMN IF EXISTS controller_state") + handleError(tx, err) + + _, err = tx.Exec("ALTER TABLE operating_system DROP COLUMN IF EXISTS ipxe_os_scope") + handleError(tx, err) + _, err = tx.Exec("ALTER TABLE operating_system DROP COLUMN IF EXISTS ipxe_template_definition_hash") + handleError(tx, err) + _, err = tx.Exec("ALTER TABLE operating_system DROP COLUMN IF EXISTS ipxe_template_artifacts") + handleError(tx, err) + _, err = tx.Exec("ALTER TABLE operating_system DROP COLUMN IF EXISTS ipxe_template_parameters") + handleError(tx, err) + _, err = tx.Exec("ALTER TABLE operating_system DROP COLUMN IF EXISTS ipxe_template_id") + handleError(tx, err) + + _, err = tx.Exec("DROP TABLE IF EXISTS ipxe_template_site_association") + handleError(tx, err) + _, err = tx.Exec("DROP TABLE IF EXISTS ipxe_template") + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [down migration] Dropped iPXE template tables and Operating System scope/template columns. ") + return nil + }) +} From edc72f2644a465d70dbaacfd6db4eaefff06ccd0 Mon Sep 17 00:00:00 2001 From: Kyle Felter Date: Wed, 24 Jun 2026 00:36:08 -0500 Subject: [PATCH 2/5] feat: Add operating system and iPXE template inventory sync workflows Signed-off-by: Kyle Felter --- rest-api/db/pkg/db/model/operatingsystem.go | 24 +- .../model/operatingsystemsiteassociation.go | 11 + .../site-agent/workflows/v1/inventory.pb.go | 426 +++++++++++----- .../site-agent/workflows/v1/inventory.proto | 28 ++ rest-api/workflow/cmd/workflow/main.go | 12 + .../pkg/activity/ipxetemplate/ipxetemplate.go | 264 ++++++++++ .../ipxetemplate/ipxetemplate_test.go | 434 ++++++++++++++++ .../operatingsystem/operatingsystem.go | 474 ++++++++++++++++-- rest-api/workflow/pkg/util/testing.go | 14 + .../pkg/workflow/ipxetemplate/update.go | 82 +++ .../pkg/workflow/ipxetemplate/update_test.go | 104 ++++ .../pkg/workflow/operatingsystem/update.go | 80 ++- 12 files changed, 1787 insertions(+), 166 deletions(-) create mode 100644 rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go create mode 100644 rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go create mode 100644 rest-api/workflow/pkg/workflow/ipxetemplate/update.go create mode 100644 rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go diff --git a/rest-api/db/pkg/db/model/operatingsystem.go b/rest-api/db/pkg/db/model/operatingsystem.go index 2a64cb8f8d..fa228b9bc6 100644 --- a/rest-api/db/pkg/db/model/operatingsystem.go +++ b/rest-api/db/pkg/db/model/operatingsystem.go @@ -109,6 +109,21 @@ var ( OperatingSystemIpxeArtifactCacheStrategyCachedOnly: cwssaws.IpxeTemplateArtifactCacheStrategy_CACHED_ONLY, OperatingSystemIpxeArtifactCacheStrategyRemoteOnly: cwssaws.IpxeTemplateArtifactCacheStrategy_REMOTE_ONLY, } + + // OperatingSystemTypeFromProtoMap maps nico-core OS types to their model string values. + OperatingSystemTypeFromProtoMap = map[cwssaws.OperatingSystemType]string{ + cwssaws.OperatingSystemType_OS_TYPE_IPXE: OperatingSystemTypeIPXE, + cwssaws.OperatingSystemType_OS_TYPE_TEMPLATED_IPXE: OperatingSystemTypeTemplatedIPXE, + } + + // OperatingSystemStatusFromProtoMap maps nico-core tenant states to OperatingSystem status values. + OperatingSystemStatusFromProtoMap = map[cwssaws.TenantState]string{ + cwssaws.TenantState_PROVISIONING: OperatingSystemStatusProvisioning, + cwssaws.TenantState_READY: OperatingSystemStatusReady, + cwssaws.TenantState_CONFIGURING: OperatingSystemStatusSyncing, + cwssaws.TenantState_TERMINATING: OperatingSystemStatusDeleting, + cwssaws.TenantState_FAILED: OperatingSystemStatusError, + } ) // IsIPXEType returns true if the given OS type is any iPXE variant (raw script or templated). @@ -292,6 +307,9 @@ func (os *OperatingSystem) ToDeletionRequestProto(tenantOrg string) *cwssaws.Del // OperatingSystemCreateInput input parameters for Create method type OperatingSystemCreateInput struct { + // 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 @@ -460,8 +478,12 @@ 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(), + ID: id, Name: input.Name, Description: input.Description, Org: input.Org, diff --git a/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go b/rest-api/db/pkg/db/model/operatingsystemsiteassociation.go index 3baa56b9bc..709e3c28ed 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" + + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" ) var ( @@ -56,6 +58,15 @@ var ( OperatingSystemSiteAssociationStatusError: true, OperatingSystemSiteAssociationStatusDeleting: true, } + + // OperatingSystemSiteAssociationStatusFromProtoMap maps nico-core tenant states to per-site association status values. + OperatingSystemSiteAssociationStatusFromProtoMap = map[cwssaws.TenantState]string{ + cwssaws.TenantState_PROVISIONING: OperatingSystemSiteAssociationStatusSyncing, + cwssaws.TenantState_READY: OperatingSystemSiteAssociationStatusSynced, + cwssaws.TenantState_CONFIGURING: OperatingSystemSiteAssociationStatusSyncing, + cwssaws.TenantState_TERMINATING: OperatingSystemSiteAssociationStatusDeleting, + cwssaws.TenantState_FAILED: OperatingSystemSiteAssociationStatusError, + } ) // OperatingSystemSiteAssociation associates an OperatingSystem with different Sites diff --git a/rest-api/workflow-schema/schema/site-agent/workflows/v1/inventory.pb.go b/rest-api/workflow-schema/schema/site-agent/workflows/v1/inventory.pb.go index 0902492091..b09c8c79be 100644 --- a/rest-api/workflow-schema/schema/site-agent/workflows/v1/inventory.pb.go +++ b/rest-api/workflow-schema/schema/site-agent/workflows/v1/inventory.pb.go @@ -1230,6 +1230,170 @@ func (x *OsImageInventory) GetInventoryPage() *InventoryPage { return nil } +// OperatingSystemInventory - inventory info for all OS definitions periodically collected from carbide-core +type OperatingSystemInventory struct { + state protoimpl.MessageState `protogen:"open.v1"` + // List of Operating Systems (active records only; deletions are detected by absence) + OperatingSystems []*OperatingSystem `protobuf:"bytes,1,rep,name=operating_systems,json=operatingSystems,proto3" json:"operating_systems,omitempty"` + // Reported timestamp of inventory + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + // Status of Inventory + InventoryStatus InventoryStatus `protobuf:"varint,3,opt,name=inventory_status,json=inventoryStatus,proto3,enum=workflows.v1.InventoryStatus" json:"inventory_status,omitempty"` + // Message for status + StatusMsg string `protobuf:"bytes,4,opt,name=status_msg,json=statusMsg,proto3" json:"status_msg,omitempty"` + // Inventory page information + InventoryPage *InventoryPage `protobuf:"bytes,5,opt,name=inventory_page,json=inventoryPage,proto3" json:"inventory_page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OperatingSystemInventory) Reset() { + *x = OperatingSystemInventory{} + mi := &file_inventory_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OperatingSystemInventory) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OperatingSystemInventory) ProtoMessage() {} + +func (x *OperatingSystemInventory) ProtoReflect() protoreflect.Message { + mi := &file_inventory_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OperatingSystemInventory.ProtoReflect.Descriptor instead. +func (*OperatingSystemInventory) Descriptor() ([]byte, []int) { + return file_inventory_proto_rawDescGZIP(), []int{14} +} + +func (x *OperatingSystemInventory) GetOperatingSystems() []*OperatingSystem { + if x != nil { + return x.OperatingSystems + } + return nil +} + +func (x *OperatingSystemInventory) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +func (x *OperatingSystemInventory) GetInventoryStatus() InventoryStatus { + if x != nil { + return x.InventoryStatus + } + return InventoryStatus_INVENTORY_STATUS_UNSPECIFIED +} + +func (x *OperatingSystemInventory) GetStatusMsg() string { + if x != nil { + return x.StatusMsg + } + return "" +} + +func (x *OperatingSystemInventory) GetInventoryPage() *InventoryPage { + if x != nil { + return x.InventoryPage + } + return nil +} + +// IpxeTemplateInventory - inventory info of all iPXE templates periodically collected from site +type IpxeTemplateInventory struct { + state protoimpl.MessageState `protogen:"open.v1"` + // List of iPXE templates + Templates []*IpxeTemplate `protobuf:"bytes,1,rep,name=templates,proto3" json:"templates,omitempty"` + // Reported timestamp of iPXE template inventory + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + // Status of Inventory + InventoryStatus InventoryStatus `protobuf:"varint,3,opt,name=inventory_status,json=inventoryStatus,proto3,enum=workflows.v1.InventoryStatus" json:"inventory_status,omitempty"` + // Status message + StatusMsg string `protobuf:"bytes,4,opt,name=status_msg,json=statusMsg,proto3" json:"status_msg,omitempty"` + // Inventory page information + InventoryPage *InventoryPage `protobuf:"bytes,5,opt,name=inventory_page,json=inventoryPage,proto3" json:"inventory_page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IpxeTemplateInventory) Reset() { + *x = IpxeTemplateInventory{} + mi := &file_inventory_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IpxeTemplateInventory) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IpxeTemplateInventory) ProtoMessage() {} + +func (x *IpxeTemplateInventory) ProtoReflect() protoreflect.Message { + mi := &file_inventory_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IpxeTemplateInventory.ProtoReflect.Descriptor instead. +func (*IpxeTemplateInventory) Descriptor() ([]byte, []int) { + return file_inventory_proto_rawDescGZIP(), []int{15} +} + +func (x *IpxeTemplateInventory) GetTemplates() []*IpxeTemplate { + if x != nil { + return x.Templates + } + return nil +} + +func (x *IpxeTemplateInventory) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +func (x *IpxeTemplateInventory) GetInventoryStatus() InventoryStatus { + if x != nil { + return x.InventoryStatus + } + return InventoryStatus_INVENTORY_STATUS_UNSPECIFIED +} + +func (x *IpxeTemplateInventory) GetStatusMsg() string { + if x != nil { + return x.StatusMsg + } + return "" +} + +func (x *IpxeTemplateInventory) GetInventoryPage() *InventoryPage { + if x != nil { + return x.InventoryPage + } + return nil +} + // SkuInventory - inventory info of all SKUs on Site, collected periodically type SkuInventory struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1249,7 +1413,7 @@ type SkuInventory struct { func (x *SkuInventory) Reset() { *x = SkuInventory{} - mi := &file_inventory_proto_msgTypes[14] + mi := &file_inventory_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1261,7 +1425,7 @@ func (x *SkuInventory) String() string { func (*SkuInventory) ProtoMessage() {} func (x *SkuInventory) ProtoReflect() protoreflect.Message { - mi := &file_inventory_proto_msgTypes[14] + mi := &file_inventory_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1274,7 +1438,7 @@ func (x *SkuInventory) ProtoReflect() protoreflect.Message { // Deprecated: Use SkuInventory.ProtoReflect.Descriptor instead. func (*SkuInventory) Descriptor() ([]byte, []int) { - return file_inventory_proto_rawDescGZIP(), []int{14} + return file_inventory_proto_rawDescGZIP(), []int{16} } func (x *SkuInventory) GetInventoryStatus() InventoryStatus { @@ -1331,7 +1495,7 @@ type SSHKeyGroupInventory struct { func (x *SSHKeyGroupInventory) Reset() { *x = SSHKeyGroupInventory{} - mi := &file_inventory_proto_msgTypes[15] + mi := &file_inventory_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1343,7 +1507,7 @@ func (x *SSHKeyGroupInventory) String() string { func (*SSHKeyGroupInventory) ProtoMessage() {} func (x *SSHKeyGroupInventory) ProtoReflect() protoreflect.Message { - mi := &file_inventory_proto_msgTypes[15] + mi := &file_inventory_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1356,7 +1520,7 @@ func (x *SSHKeyGroupInventory) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHKeyGroupInventory.ProtoReflect.Descriptor instead. func (*SSHKeyGroupInventory) Descriptor() ([]byte, []int) { - return file_inventory_proto_rawDescGZIP(), []int{15} + return file_inventory_proto_rawDescGZIP(), []int{17} } func (x *SSHKeyGroupInventory) GetTenantKeysets() []*TenantKeyset { @@ -1413,7 +1577,7 @@ type SubnetInventory struct { func (x *SubnetInventory) Reset() { *x = SubnetInventory{} - mi := &file_inventory_proto_msgTypes[16] + mi := &file_inventory_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1425,7 +1589,7 @@ func (x *SubnetInventory) String() string { func (*SubnetInventory) ProtoMessage() {} func (x *SubnetInventory) ProtoReflect() protoreflect.Message { - mi := &file_inventory_proto_msgTypes[16] + mi := &file_inventory_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1438,7 +1602,7 @@ func (x *SubnetInventory) ProtoReflect() protoreflect.Message { // Deprecated: Use SubnetInventory.ProtoReflect.Descriptor instead. func (*SubnetInventory) Descriptor() ([]byte, []int) { - return file_inventory_proto_rawDescGZIP(), []int{16} + return file_inventory_proto_rawDescGZIP(), []int{18} } func (x *SubnetInventory) GetSegments() []*NetworkSegment { @@ -1495,7 +1659,7 @@ type TenantInventory struct { func (x *TenantInventory) Reset() { *x = TenantInventory{} - mi := &file_inventory_proto_msgTypes[17] + mi := &file_inventory_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1507,7 +1671,7 @@ func (x *TenantInventory) String() string { func (*TenantInventory) ProtoMessage() {} func (x *TenantInventory) ProtoReflect() protoreflect.Message { - mi := &file_inventory_proto_msgTypes[17] + mi := &file_inventory_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1520,7 +1684,7 @@ func (x *TenantInventory) ProtoReflect() protoreflect.Message { // Deprecated: Use TenantInventory.ProtoReflect.Descriptor instead. func (*TenantInventory) Descriptor() ([]byte, []int) { - return file_inventory_proto_rawDescGZIP(), []int{17} + return file_inventory_proto_rawDescGZIP(), []int{19} } func (x *TenantInventory) GetTenants() []*Tenant { @@ -1579,7 +1743,7 @@ type VPCInventory struct { func (x *VPCInventory) Reset() { *x = VPCInventory{} - mi := &file_inventory_proto_msgTypes[18] + mi := &file_inventory_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1591,7 +1755,7 @@ func (x *VPCInventory) String() string { func (*VPCInventory) ProtoMessage() {} func (x *VPCInventory) ProtoReflect() protoreflect.Message { - mi := &file_inventory_proto_msgTypes[18] + mi := &file_inventory_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1604,7 +1768,7 @@ func (x *VPCInventory) ProtoReflect() protoreflect.Message { // Deprecated: Use VPCInventory.ProtoReflect.Descriptor instead. func (*VPCInventory) Descriptor() ([]byte, []int) { - return file_inventory_proto_rawDescGZIP(), []int{18} + return file_inventory_proto_rawDescGZIP(), []int{20} } func (x *VPCInventory) GetVpcs() []*Vpc { @@ -1668,7 +1832,7 @@ type VPCPeeringInventory struct { func (x *VPCPeeringInventory) Reset() { *x = VPCPeeringInventory{} - mi := &file_inventory_proto_msgTypes[19] + mi := &file_inventory_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1680,7 +1844,7 @@ func (x *VPCPeeringInventory) String() string { func (*VPCPeeringInventory) ProtoMessage() {} func (x *VPCPeeringInventory) ProtoReflect() protoreflect.Message { - mi := &file_inventory_proto_msgTypes[19] + mi := &file_inventory_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1693,7 +1857,7 @@ func (x *VPCPeeringInventory) ProtoReflect() protoreflect.Message { // Deprecated: Use VPCPeeringInventory.ProtoReflect.Descriptor instead. func (*VPCPeeringInventory) Descriptor() ([]byte, []int) { - return file_inventory_proto_rawDescGZIP(), []int{19} + return file_inventory_proto_rawDescGZIP(), []int{21} } func (x *VPCPeeringInventory) GetVpcPeerings() []*VpcPeering { @@ -1750,7 +1914,7 @@ type VpcPrefixInventory struct { func (x *VpcPrefixInventory) Reset() { *x = VpcPrefixInventory{} - mi := &file_inventory_proto_msgTypes[20] + mi := &file_inventory_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1762,7 +1926,7 @@ func (x *VpcPrefixInventory) String() string { func (*VpcPrefixInventory) ProtoMessage() {} func (x *VpcPrefixInventory) ProtoReflect() protoreflect.Message { - mi := &file_inventory_proto_msgTypes[20] + mi := &file_inventory_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1775,7 +1939,7 @@ func (x *VpcPrefixInventory) ProtoReflect() protoreflect.Message { // Deprecated: Use VpcPrefixInventory.ProtoReflect.Descriptor instead. func (*VpcPrefixInventory) Descriptor() ([]byte, []int) { - return file_inventory_proto_rawDescGZIP(), []int{20} + return file_inventory_proto_rawDescGZIP(), []int{22} } func (x *VpcPrefixInventory) GetVpcPrefixes() []*VpcPrefix { @@ -1918,6 +2082,20 @@ const file_inventory_proto_rawDesc = "" + "\x10inventory_status\x18\x03 \x01(\x0e2\x1d.workflows.v1.InventoryStatusR\x0finventoryStatus\x12\x1d\n" + "\n" + "status_msg\x18\x04 \x01(\tR\tstatusMsg\x12B\n" + + "\x0einventory_page\x18\x05 \x01(\v2\x1b.workflows.v1.InventoryPageR\rinventoryPage\"\xc6\x02\n" + + "\x18OperatingSystemInventory\x12C\n" + + "\x11operating_systems\x18\x01 \x03(\v2\x16.forge.OperatingSystemR\x10operatingSystems\x128\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12H\n" + + "\x10inventory_status\x18\x03 \x01(\x0e2\x1d.workflows.v1.InventoryStatusR\x0finventoryStatus\x12\x1d\n" + + "\n" + + "status_msg\x18\x04 \x01(\tR\tstatusMsg\x12B\n" + + "\x0einventory_page\x18\x05 \x01(\v2\x1b.workflows.v1.InventoryPageR\rinventoryPage\"\xb1\x02\n" + + "\x15IpxeTemplateInventory\x121\n" + + "\ttemplates\x18\x01 \x03(\v2\x13.forge.IpxeTemplateR\ttemplates\x128\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12H\n" + + "\x10inventory_status\x18\x03 \x01(\x0e2\x1d.workflows.v1.InventoryStatusR\x0finventoryStatus\x12\x1d\n" + + "\n" + + "status_msg\x18\x04 \x01(\tR\tstatusMsg\x12B\n" + "\x0einventory_page\x18\x05 \x01(\v2\x1b.workflows.v1.InventoryPageR\rinventoryPage\"\x95\x02\n" + "\fSkuInventory\x12H\n" + "\x10inventory_status\x18\x01 \x01(\x0e2\x1d.workflows.v1.InventoryStatusR\x0finventoryStatus\x12\x1d\n" + @@ -1989,7 +2167,7 @@ func file_inventory_proto_rawDescGZIP() []byte { } var file_inventory_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_inventory_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_inventory_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_inventory_proto_goTypes = []any{ (InventoryStatus)(0), // 0: workflows.v1.InventoryStatus (*InventoryPage)(nil), // 1: workflows.v1.InventoryPage @@ -2006,128 +2184,140 @@ var file_inventory_proto_goTypes = []any{ (*NetworkSecurityGroupInventory)(nil), // 12: workflows.v1.NetworkSecurityGroupInventory (*NVLinkLogicalPartitionInventory)(nil), // 13: workflows.v1.NVLinkLogicalPartitionInventory (*OsImageInventory)(nil), // 14: workflows.v1.OsImageInventory - (*SkuInventory)(nil), // 15: workflows.v1.SkuInventory - (*SSHKeyGroupInventory)(nil), // 16: workflows.v1.SSHKeyGroupInventory - (*SubnetInventory)(nil), // 17: workflows.v1.SubnetInventory - (*TenantInventory)(nil), // 18: workflows.v1.TenantInventory - (*VPCInventory)(nil), // 19: workflows.v1.VPCInventory - (*VPCPeeringInventory)(nil), // 20: workflows.v1.VPCPeeringInventory - (*VpcPrefixInventory)(nil), // 21: workflows.v1.VpcPrefixInventory - (*timestamppb.Timestamp)(nil), // 22: google.protobuf.Timestamp - (*DpuExtensionService)(nil), // 23: forge.DpuExtensionService - (*ExpectedMachine)(nil), // 24: forge.ExpectedMachine - (*LinkedExpectedMachine)(nil), // 25: forge.LinkedExpectedMachine - (*ExpectedRack)(nil), // 26: forge.ExpectedRack - (*ExpectedPowerShelf)(nil), // 27: forge.ExpectedPowerShelf - (*LinkedExpectedPowerShelf)(nil), // 28: forge.LinkedExpectedPowerShelf - (*ExpectedSwitch)(nil), // 29: forge.ExpectedSwitch - (*LinkedExpectedSwitch)(nil), // 30: forge.LinkedExpectedSwitch - (*IBPartition)(nil), // 31: forge.IBPartition - (*Instance)(nil), // 32: forge.Instance - (*NetworkSecurityGroupPropagationObjectStatus)(nil), // 33: forge.NetworkSecurityGroupPropagationObjectStatus - (*InstanceType)(nil), // 34: forge.InstanceType - (*Machine)(nil), // 35: forge.Machine - (*DiscoveryInfo)(nil), // 36: machine_discovery.DiscoveryInfo - (*NetworkSecurityGroup)(nil), // 37: forge.NetworkSecurityGroup - (*NVLinkLogicalPartition)(nil), // 38: forge.NVLinkLogicalPartition - (*OsImage)(nil), // 39: forge.OsImage - (*Sku)(nil), // 40: forge.Sku - (*TenantKeyset)(nil), // 41: forge.TenantKeyset - (*NetworkSegment)(nil), // 42: forge.NetworkSegment - (*Tenant)(nil), // 43: forge.Tenant - (*Vpc)(nil), // 44: forge.Vpc - (*VpcPeering)(nil), // 45: forge.VpcPeering - (*VpcPrefix)(nil), // 46: forge.VpcPrefix + (*OperatingSystemInventory)(nil), // 15: workflows.v1.OperatingSystemInventory + (*IpxeTemplateInventory)(nil), // 16: workflows.v1.IpxeTemplateInventory + (*SkuInventory)(nil), // 17: workflows.v1.SkuInventory + (*SSHKeyGroupInventory)(nil), // 18: workflows.v1.SSHKeyGroupInventory + (*SubnetInventory)(nil), // 19: workflows.v1.SubnetInventory + (*TenantInventory)(nil), // 20: workflows.v1.TenantInventory + (*VPCInventory)(nil), // 21: workflows.v1.VPCInventory + (*VPCPeeringInventory)(nil), // 22: workflows.v1.VPCPeeringInventory + (*VpcPrefixInventory)(nil), // 23: workflows.v1.VpcPrefixInventory + (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp + (*DpuExtensionService)(nil), // 25: forge.DpuExtensionService + (*ExpectedMachine)(nil), // 26: forge.ExpectedMachine + (*LinkedExpectedMachine)(nil), // 27: forge.LinkedExpectedMachine + (*ExpectedRack)(nil), // 28: forge.ExpectedRack + (*ExpectedPowerShelf)(nil), // 29: forge.ExpectedPowerShelf + (*LinkedExpectedPowerShelf)(nil), // 30: forge.LinkedExpectedPowerShelf + (*ExpectedSwitch)(nil), // 31: forge.ExpectedSwitch + (*LinkedExpectedSwitch)(nil), // 32: forge.LinkedExpectedSwitch + (*IBPartition)(nil), // 33: forge.IBPartition + (*Instance)(nil), // 34: forge.Instance + (*NetworkSecurityGroupPropagationObjectStatus)(nil), // 35: forge.NetworkSecurityGroupPropagationObjectStatus + (*InstanceType)(nil), // 36: forge.InstanceType + (*Machine)(nil), // 37: forge.Machine + (*DiscoveryInfo)(nil), // 38: machine_discovery.DiscoveryInfo + (*NetworkSecurityGroup)(nil), // 39: forge.NetworkSecurityGroup + (*NVLinkLogicalPartition)(nil), // 40: forge.NVLinkLogicalPartition + (*OsImage)(nil), // 41: forge.OsImage + (*OperatingSystem)(nil), // 42: forge.OperatingSystem + (*IpxeTemplate)(nil), // 43: forge.IpxeTemplate + (*Sku)(nil), // 44: forge.Sku + (*TenantKeyset)(nil), // 45: forge.TenantKeyset + (*NetworkSegment)(nil), // 46: forge.NetworkSegment + (*Tenant)(nil), // 47: forge.Tenant + (*Vpc)(nil), // 48: forge.Vpc + (*VpcPeering)(nil), // 49: forge.VpcPeering + (*VpcPrefix)(nil), // 50: forge.VpcPrefix } var file_inventory_proto_depIdxs = []int32{ 0, // 0: workflows.v1.DpuExtensionServiceInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 1: workflows.v1.DpuExtensionServiceInventory.timestamp:type_name -> google.protobuf.Timestamp - 23, // 2: workflows.v1.DpuExtensionServiceInventory.dpu_extension_services:type_name -> forge.DpuExtensionService + 24, // 1: workflows.v1.DpuExtensionServiceInventory.timestamp:type_name -> google.protobuf.Timestamp + 25, // 2: workflows.v1.DpuExtensionServiceInventory.dpu_extension_services:type_name -> forge.DpuExtensionService 1, // 3: workflows.v1.DpuExtensionServiceInventory.inventory_page:type_name -> workflows.v1.InventoryPage 0, // 4: workflows.v1.ExpectedMachineInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 5: workflows.v1.ExpectedMachineInventory.timestamp:type_name -> google.protobuf.Timestamp - 24, // 6: workflows.v1.ExpectedMachineInventory.expected_machines:type_name -> forge.ExpectedMachine + 24, // 5: workflows.v1.ExpectedMachineInventory.timestamp:type_name -> google.protobuf.Timestamp + 26, // 6: workflows.v1.ExpectedMachineInventory.expected_machines:type_name -> forge.ExpectedMachine 1, // 7: workflows.v1.ExpectedMachineInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 25, // 8: workflows.v1.ExpectedMachineInventory.linked_machines:type_name -> forge.LinkedExpectedMachine + 27, // 8: workflows.v1.ExpectedMachineInventory.linked_machines:type_name -> forge.LinkedExpectedMachine 0, // 9: workflows.v1.ExpectedRackInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 10: workflows.v1.ExpectedRackInventory.timestamp:type_name -> google.protobuf.Timestamp - 26, // 11: workflows.v1.ExpectedRackInventory.expected_racks:type_name -> forge.ExpectedRack + 24, // 10: workflows.v1.ExpectedRackInventory.timestamp:type_name -> google.protobuf.Timestamp + 28, // 11: workflows.v1.ExpectedRackInventory.expected_racks:type_name -> forge.ExpectedRack 1, // 12: workflows.v1.ExpectedRackInventory.inventory_page:type_name -> workflows.v1.InventoryPage 0, // 13: workflows.v1.ExpectedPowerShelfInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 14: workflows.v1.ExpectedPowerShelfInventory.timestamp:type_name -> google.protobuf.Timestamp - 27, // 15: workflows.v1.ExpectedPowerShelfInventory.expected_power_shelves:type_name -> forge.ExpectedPowerShelf + 24, // 14: workflows.v1.ExpectedPowerShelfInventory.timestamp:type_name -> google.protobuf.Timestamp + 29, // 15: workflows.v1.ExpectedPowerShelfInventory.expected_power_shelves:type_name -> forge.ExpectedPowerShelf 1, // 16: workflows.v1.ExpectedPowerShelfInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 28, // 17: workflows.v1.ExpectedPowerShelfInventory.linked_power_shelves:type_name -> forge.LinkedExpectedPowerShelf + 30, // 17: workflows.v1.ExpectedPowerShelfInventory.linked_power_shelves:type_name -> forge.LinkedExpectedPowerShelf 0, // 18: workflows.v1.ExpectedSwitchInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 19: workflows.v1.ExpectedSwitchInventory.timestamp:type_name -> google.protobuf.Timestamp - 29, // 20: workflows.v1.ExpectedSwitchInventory.expected_switches:type_name -> forge.ExpectedSwitch + 24, // 19: workflows.v1.ExpectedSwitchInventory.timestamp:type_name -> google.protobuf.Timestamp + 31, // 20: workflows.v1.ExpectedSwitchInventory.expected_switches:type_name -> forge.ExpectedSwitch 1, // 21: workflows.v1.ExpectedSwitchInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 30, // 22: workflows.v1.ExpectedSwitchInventory.linked_switches:type_name -> forge.LinkedExpectedSwitch + 32, // 22: workflows.v1.ExpectedSwitchInventory.linked_switches:type_name -> forge.LinkedExpectedSwitch 0, // 23: workflows.v1.InfiniBandPartitionInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 24: workflows.v1.InfiniBandPartitionInventory.timestamp:type_name -> google.protobuf.Timestamp - 31, // 25: workflows.v1.InfiniBandPartitionInventory.ib_partitions:type_name -> forge.IBPartition + 24, // 24: workflows.v1.InfiniBandPartitionInventory.timestamp:type_name -> google.protobuf.Timestamp + 33, // 25: workflows.v1.InfiniBandPartitionInventory.ib_partitions:type_name -> forge.IBPartition 1, // 26: workflows.v1.InfiniBandPartitionInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 32, // 27: workflows.v1.InstanceInventory.instances:type_name -> forge.Instance - 33, // 28: workflows.v1.InstanceInventory.network_security_group_propagations:type_name -> forge.NetworkSecurityGroupPropagationObjectStatus - 22, // 29: workflows.v1.InstanceInventory.timestamp:type_name -> google.protobuf.Timestamp + 34, // 27: workflows.v1.InstanceInventory.instances:type_name -> forge.Instance + 35, // 28: workflows.v1.InstanceInventory.network_security_group_propagations:type_name -> forge.NetworkSecurityGroupPropagationObjectStatus + 24, // 29: workflows.v1.InstanceInventory.timestamp:type_name -> google.protobuf.Timestamp 0, // 30: workflows.v1.InstanceInventory.inventory_status:type_name -> workflows.v1.InventoryStatus 1, // 31: workflows.v1.InstanceInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 34, // 32: workflows.v1.InstanceTypeInventory.instance_types:type_name -> forge.InstanceType - 22, // 33: workflows.v1.InstanceTypeInventory.timestamp:type_name -> google.protobuf.Timestamp + 36, // 32: workflows.v1.InstanceTypeInventory.instance_types:type_name -> forge.InstanceType + 24, // 33: workflows.v1.InstanceTypeInventory.timestamp:type_name -> google.protobuf.Timestamp 0, // 34: workflows.v1.InstanceTypeInventory.inventory_status:type_name -> workflows.v1.InventoryStatus 1, // 35: workflows.v1.InstanceTypeInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 35, // 36: workflows.v1.MachineInfo.machine:type_name -> forge.Machine - 36, // 37: workflows.v1.MachineInfo.discovery_info:type_name -> machine_discovery.DiscoveryInfo + 37, // 36: workflows.v1.MachineInfo.machine:type_name -> forge.Machine + 38, // 37: workflows.v1.MachineInfo.discovery_info:type_name -> machine_discovery.DiscoveryInfo 10, // 38: workflows.v1.MachineInventory.machines:type_name -> workflows.v1.MachineInfo - 22, // 39: workflows.v1.MachineInventory.timestamp:type_name -> google.protobuf.Timestamp + 24, // 39: workflows.v1.MachineInventory.timestamp:type_name -> google.protobuf.Timestamp 0, // 40: workflows.v1.MachineInventory.inventory_status:type_name -> workflows.v1.InventoryStatus 1, // 41: workflows.v1.MachineInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 37, // 42: workflows.v1.NetworkSecurityGroupInventory.network_security_groups:type_name -> forge.NetworkSecurityGroup - 22, // 43: workflows.v1.NetworkSecurityGroupInventory.timestamp:type_name -> google.protobuf.Timestamp + 39, // 42: workflows.v1.NetworkSecurityGroupInventory.network_security_groups:type_name -> forge.NetworkSecurityGroup + 24, // 43: workflows.v1.NetworkSecurityGroupInventory.timestamp:type_name -> google.protobuf.Timestamp 0, // 44: workflows.v1.NetworkSecurityGroupInventory.inventory_status:type_name -> workflows.v1.InventoryStatus 1, // 45: workflows.v1.NetworkSecurityGroupInventory.inventory_page:type_name -> workflows.v1.InventoryPage 0, // 46: workflows.v1.NVLinkLogicalPartitionInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 47: workflows.v1.NVLinkLogicalPartitionInventory.timestamp:type_name -> google.protobuf.Timestamp - 38, // 48: workflows.v1.NVLinkLogicalPartitionInventory.partitions:type_name -> forge.NVLinkLogicalPartition + 24, // 47: workflows.v1.NVLinkLogicalPartitionInventory.timestamp:type_name -> google.protobuf.Timestamp + 40, // 48: workflows.v1.NVLinkLogicalPartitionInventory.partitions:type_name -> forge.NVLinkLogicalPartition 1, // 49: workflows.v1.NVLinkLogicalPartitionInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 39, // 50: workflows.v1.OsImageInventory.os_images:type_name -> forge.OsImage - 22, // 51: workflows.v1.OsImageInventory.timestamp:type_name -> google.protobuf.Timestamp + 41, // 50: workflows.v1.OsImageInventory.os_images:type_name -> forge.OsImage + 24, // 51: workflows.v1.OsImageInventory.timestamp:type_name -> google.protobuf.Timestamp 0, // 52: workflows.v1.OsImageInventory.inventory_status:type_name -> workflows.v1.InventoryStatus 1, // 53: workflows.v1.OsImageInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 0, // 54: workflows.v1.SkuInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 22, // 55: workflows.v1.SkuInventory.timestamp:type_name -> google.protobuf.Timestamp - 40, // 56: workflows.v1.SkuInventory.skus:type_name -> forge.Sku - 1, // 57: workflows.v1.SkuInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 41, // 58: workflows.v1.SSHKeyGroupInventory.tenant_keysets:type_name -> forge.TenantKeyset - 22, // 59: workflows.v1.SSHKeyGroupInventory.timestamp:type_name -> google.protobuf.Timestamp - 0, // 60: workflows.v1.SSHKeyGroupInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 1, // 61: workflows.v1.SSHKeyGroupInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 42, // 62: workflows.v1.SubnetInventory.segments:type_name -> forge.NetworkSegment - 22, // 63: workflows.v1.SubnetInventory.timestamp:type_name -> google.protobuf.Timestamp - 0, // 64: workflows.v1.SubnetInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 1, // 65: workflows.v1.SubnetInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 43, // 66: workflows.v1.TenantInventory.tenants:type_name -> forge.Tenant - 22, // 67: workflows.v1.TenantInventory.timestamp:type_name -> google.protobuf.Timestamp - 0, // 68: workflows.v1.TenantInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 1, // 69: workflows.v1.TenantInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 44, // 70: workflows.v1.VPCInventory.vpcs:type_name -> forge.Vpc - 33, // 71: workflows.v1.VPCInventory.network_security_group_propagations:type_name -> forge.NetworkSecurityGroupPropagationObjectStatus - 22, // 72: workflows.v1.VPCInventory.timestamp:type_name -> google.protobuf.Timestamp - 0, // 73: workflows.v1.VPCInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 1, // 74: workflows.v1.VPCInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 45, // 75: workflows.v1.VPCPeeringInventory.vpc_peerings:type_name -> forge.VpcPeering - 22, // 76: workflows.v1.VPCPeeringInventory.timestamp:type_name -> google.protobuf.Timestamp - 0, // 77: workflows.v1.VPCPeeringInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 1, // 78: workflows.v1.VPCPeeringInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 46, // 79: workflows.v1.VpcPrefixInventory.vpc_prefixes:type_name -> forge.VpcPrefix - 22, // 80: workflows.v1.VpcPrefixInventory.timestamp:type_name -> google.protobuf.Timestamp - 0, // 81: workflows.v1.VpcPrefixInventory.inventory_status:type_name -> workflows.v1.InventoryStatus - 1, // 82: workflows.v1.VpcPrefixInventory.inventory_page:type_name -> workflows.v1.InventoryPage - 83, // [83:83] is the sub-list for method output_type - 83, // [83:83] is the sub-list for method input_type - 83, // [83:83] is the sub-list for extension type_name - 83, // [83:83] is the sub-list for extension extendee - 0, // [0:83] is the sub-list for field type_name + 42, // 54: workflows.v1.OperatingSystemInventory.operating_systems:type_name -> forge.OperatingSystem + 24, // 55: workflows.v1.OperatingSystemInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 56: workflows.v1.OperatingSystemInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 57: workflows.v1.OperatingSystemInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 43, // 58: workflows.v1.IpxeTemplateInventory.templates:type_name -> forge.IpxeTemplate + 24, // 59: workflows.v1.IpxeTemplateInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 60: workflows.v1.IpxeTemplateInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 61: workflows.v1.IpxeTemplateInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 0, // 62: workflows.v1.SkuInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 24, // 63: workflows.v1.SkuInventory.timestamp:type_name -> google.protobuf.Timestamp + 44, // 64: workflows.v1.SkuInventory.skus:type_name -> forge.Sku + 1, // 65: workflows.v1.SkuInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 45, // 66: workflows.v1.SSHKeyGroupInventory.tenant_keysets:type_name -> forge.TenantKeyset + 24, // 67: workflows.v1.SSHKeyGroupInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 68: workflows.v1.SSHKeyGroupInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 69: workflows.v1.SSHKeyGroupInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 46, // 70: workflows.v1.SubnetInventory.segments:type_name -> forge.NetworkSegment + 24, // 71: workflows.v1.SubnetInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 72: workflows.v1.SubnetInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 73: workflows.v1.SubnetInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 47, // 74: workflows.v1.TenantInventory.tenants:type_name -> forge.Tenant + 24, // 75: workflows.v1.TenantInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 76: workflows.v1.TenantInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 77: workflows.v1.TenantInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 48, // 78: workflows.v1.VPCInventory.vpcs:type_name -> forge.Vpc + 35, // 79: workflows.v1.VPCInventory.network_security_group_propagations:type_name -> forge.NetworkSecurityGroupPropagationObjectStatus + 24, // 80: workflows.v1.VPCInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 81: workflows.v1.VPCInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 82: workflows.v1.VPCInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 49, // 83: workflows.v1.VPCPeeringInventory.vpc_peerings:type_name -> forge.VpcPeering + 24, // 84: workflows.v1.VPCPeeringInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 85: workflows.v1.VPCPeeringInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 86: workflows.v1.VPCPeeringInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 50, // 87: workflows.v1.VpcPrefixInventory.vpc_prefixes:type_name -> forge.VpcPrefix + 24, // 88: workflows.v1.VpcPrefixInventory.timestamp:type_name -> google.protobuf.Timestamp + 0, // 89: workflows.v1.VpcPrefixInventory.inventory_status:type_name -> workflows.v1.InventoryStatus + 1, // 90: workflows.v1.VpcPrefixInventory.inventory_page:type_name -> workflows.v1.InventoryPage + 91, // [91:91] is the sub-list for method output_type + 91, // [91:91] is the sub-list for method input_type + 91, // [91:91] is the sub-list for extension type_name + 91, // [91:91] is the sub-list for extension extendee + 0, // [0:91] is the sub-list for field type_name } func init() { file_inventory_proto_init() } @@ -2144,7 +2334,7 @@ func file_inventory_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_inventory_proto_rawDesc), len(file_inventory_proto_rawDesc)), NumEnums: 1, - NumMessages: 21, + NumMessages: 23, NumExtensions: 0, NumServices: 0, }, diff --git a/rest-api/workflow-schema/site-agent/workflows/v1/inventory.proto b/rest-api/workflow-schema/site-agent/workflows/v1/inventory.proto index 6bc9b7208c..b990877443 100644 --- a/rest-api/workflow-schema/site-agent/workflows/v1/inventory.proto +++ b/rest-api/workflow-schema/site-agent/workflows/v1/inventory.proto @@ -216,6 +216,34 @@ message OsImageInventory { InventoryPage inventory_page = 5; } +// OperatingSystemInventory - inventory info for all OS definitions periodically collected from carbide-core +message OperatingSystemInventory { + // List of Operating Systems (active records only; deletions are detected by absence) + repeated forge.OperatingSystem operating_systems = 1; + // Reported timestamp of inventory + google.protobuf.Timestamp timestamp = 2; + // Status of Inventory + InventoryStatus inventory_status = 3; + // Message for status + string status_msg = 4; + // Inventory page information + InventoryPage inventory_page = 5; +} + +// IpxeTemplateInventory - inventory info of all iPXE templates periodically collected from site +message IpxeTemplateInventory { + // List of iPXE templates + repeated forge.IpxeTemplate templates = 1; + // Reported timestamp of iPXE template inventory + google.protobuf.Timestamp timestamp = 2; + // Status of Inventory + InventoryStatus inventory_status = 3; + // Status message + string status_msg = 4; + // Inventory page information + InventoryPage inventory_page = 5; +} + // SkuInventory - inventory info of all SKUs on Site, collected periodically message SkuInventory { // Status of Inventory diff --git a/rest-api/workflow/cmd/workflow/main.go b/rest-api/workflow/cmd/workflow/main.go index cf8f7a7691..91ba222f89 100644 --- a/rest-api/workflow/cmd/workflow/main.go +++ b/rest-api/workflow/cmd/workflow/main.go @@ -87,6 +87,9 @@ import ( osImageActivity "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/activity/operatingsystem" osImageWorkflow "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/workflow/operatingsystem" + ipxeTemplateActivity "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/activity/ipxetemplate" + ipxeTemplateWorkflow "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/workflow/ipxetemplate" + skuActivity "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/activity/sku" skuWorkflow "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/workflow/sku" @@ -292,6 +295,12 @@ func main() { // OS Image workflow w.RegisterWorkflow(osImageWorkflow.UpdateOsImageInventory) + // Operating System inventory workflow (inbound reconcile from nico-core) + w.RegisterWorkflow(osImageWorkflow.UpdateOperatingSystemInventory) + + // iPXE Template inventory workflow + w.RegisterWorkflow(ipxeTemplateWorkflow.UpdateIpxeTemplateInventory) + // VPC Prefix workflow w.RegisterWorkflow(vpcPrefixWorkflow.UpdateVpcPrefixInventory) @@ -355,6 +364,9 @@ func main() { osImageManager := osImageActivity.NewManageOsImage(dbSession, siteClientPool) w.RegisterActivity(&osImageManager) + ipxeTemplateManager := ipxeTemplateActivity.NewManageIpxeTemplate(dbSession, siteClientPool) + w.RegisterActivity(&ipxeTemplateManager) + vpcPrefixManager := vpcPrefixActivity.NewManageVpcPrefix(dbSession, siteClientPool) w.RegisterActivity(&vpcPrefixManager) diff --git a/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go new file mode 100644 index 0000000000..104ceceb06 --- /dev/null +++ b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go @@ -0,0 +1,264 @@ +/* + * 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 ipxetemplate + +import ( + "context" + "errors" + "fmt" + "reflect" + + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cdbp "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" + sc "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/client/site" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +// ManageIpxeTemplate is an activity wrapper for managing iPXE template inventory that allows +// injecting DB access +type ManageIpxeTemplate struct { + dbSession *cdb.Session + siteClientPool *sc.ClientPool +} + +// UpdateIpxeTemplatesInDB is a Temporal activity that takes a collection of iPXE template data +// pushed by the Site Agent and reconciles the DB. +// +// iPXE templates are global in REST (one row per stable template UUID), and per-site +// availability is tracked via IpxeTemplateSiteAssociation. For each reported template +// we ensure the global row exists with current fields and that an ITSA row exists for +// the reporting site. Templates no longer reported by this site have their ITSA +// removed; if no ITSA remains anywhere for a template, the global row is hard-deleted. +func (mit ManageIpxeTemplate) UpdateIpxeTemplatesInDB(ctx context.Context, siteID uuid.UUID, inventory *cwssaws.IpxeTemplateInventory) error { + logger := log.With().Str("Activity", "UpdateIpxeTemplatesInDB").Str("Site ID", siteID.String()).Logger() + + logger.Info().Msg("Starting activity") + + if inventory == nil { + logger.Error().Msg("UpdateIpxeTemplatesInDB called with nil inventory") + return errors.New("UpdateIpxeTemplatesInDB called with nil inventory") + } + + if inventory.InventoryStatus == cwssaws.InventoryStatus_INVENTORY_STATUS_FAILED { + logger.Warn().Msg("Received failed inventory status from Site Agent, skipping inventory processing") + return nil + } + + // Ensure site exists + stDAO := cdbm.NewSiteDAO(mit.dbSession) + _, err := stDAO.GetByID(ctx, nil, siteID, nil, false) + if err != nil { + if errors.Is(err, cdb.ErrDoesNotExist) { + logger.Warn().Err(err).Msg("Received inventory for unknown or deleted Site") + } else { + logger.Error().Err(err).Msg("Failed to retrieve Site from DB") + } + return err + } + + templateDAO := cdbm.NewIpxeTemplateDAO(mit.dbSession) + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(mit.dbSession) + + // Fetch existing ITSA rows for this site (with their template loaded) so we + // can reconcile against this inventory snapshot. + existingITSAs, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{SiteIDs: []uuid.UUID{siteID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + []string{cdbm.IpxeTemplateRelationName}, + ) + if err != nil { + logger.Error().Err(err).Msg("Failed to get IpxeTemplateSiteAssociation rows for Site from DB") + return err + } + + // Map existing ITSAs by template ID for quick lookup. + existingITSAByTemplateID := map[uuid.UUID]*cdbm.IpxeTemplateSiteAssociation{} + for i := range existingITSAs { + existingITSAByTemplateID[existingITSAs[i].IpxeTemplateID] = &existingITSAs[i] + } + + // Track all template IDs reported by this inventory payload (for both + // inline templates and the page item-id list, used during paginated runs). + reportedTemplateIDs := map[uuid.UUID]bool{} + + if inventory.InventoryPage != nil { + logger.Info().Msgf("Received iPXE template inventory page: %d of %d, page size: %d, total count: %d", + inventory.InventoryPage.CurrentPage, inventory.InventoryPage.TotalPages, + inventory.InventoryPage.PageSize, inventory.InventoryPage.TotalItems) + + for _, idStr := range inventory.InventoryPage.ItemIds { + if tid, perr := uuid.Parse(idStr); perr == nil { + reportedTemplateIDs[tid] = true + } + } + } + + for _, reported := range inventory.GetTemplates() { + if reported == nil { + logger.Error().Msg("Received nil iPXE template entry, skipping") + continue + } + if reported.GetId() == nil || reported.GetId().GetValue() == "" { + logger.Error().Str("Name", reported.Name).Msg("Received iPXE template with empty id, skipping") + continue + } + + templateID, perr := uuid.Parse(reported.GetId().GetValue()) + if perr != nil { + logger.Error().Err(perr).Str("Name", reported.Name).Msg("Received iPXE template with invalid id, skipping") + continue + } + + // Only propagate PUBLIC templates into REST. + if reported.Scope != cwssaws.IpxeTemplateScope_PUBLIC { + logger.Debug().Str("Name", reported.Name).Str("Scope", reported.Scope.String()).Msg("Skipping non-public iPXE template") + continue + } + + reportedTemplateIDs[templateID] = true + reportedScope := ipxeScopeToString(reported.Scope) + + // Look up the global template row (if any). + globalTmpl, getErr := templateDAO.Get(ctx, nil, templateID) + if getErr != nil && !errors.Is(getErr, cdb.ErrDoesNotExist) { + logger.Error().Err(getErr).Str("Name", reported.Name).Msg("Failed to look up global iPXE template") + return fmt.Errorf("failed to look up iPXE template %q: %w", reported.Name, getErr) + } + + if globalTmpl == nil { + // First sighting of this template across all sites — create it. + input := cdbm.IpxeTemplateCreateInput{ + ID: templateID, + Name: reported.Name, + Template: reported.Template, + RequiredParams: reported.RequiredParams, + ReservedParams: reported.ReservedParams, + RequiredArtifacts: reported.RequiredArtifacts, + Scope: reportedScope, + } + if _, cerr := templateDAO.Create(ctx, nil, input); cerr != nil { + logger.Error().Err(cerr).Str("Name", reported.Name).Msg("Failed to create iPXE template in DB") + return fmt.Errorf("failed to create iPXE template %q: %w", reported.Name, cerr) + } + } else if globalTmpl.Name != reported.Name { + // Cross-site name conflict: a template with the same ID is already + // known under a different name. Keep the existing name (first + // writer wins) and skip both the field update and the ITSA upsert. + logger.Error(). + Str("TemplateID", templateID.String()). + Str("ReportedName", reported.Name). + Str("ExistingName", globalTmpl.Name). + Msg("Template ID reused with different name, skipping") + continue + } else if globalTmpl.Scope != reportedScope || + globalTmpl.Template != reported.Template || + !reflect.DeepEqual(globalTmpl.RequiredParams, reported.RequiredParams) || + !reflect.DeepEqual(globalTmpl.ReservedParams, reported.ReservedParams) || + !reflect.DeepEqual(globalTmpl.RequiredArtifacts, reported.RequiredArtifacts) { + input := cdbm.IpxeTemplateUpdateInput{ + IpxeTemplateID: globalTmpl.ID, + Name: reported.Name, + Template: reported.Template, + RequiredParams: reported.RequiredParams, + ReservedParams: reported.ReservedParams, + RequiredArtifacts: reported.RequiredArtifacts, + Scope: reportedScope, + } + if _, uerr := templateDAO.Update(ctx, nil, input); uerr != nil { + logger.Error().Err(uerr).Str("Name", reported.Name).Msg("Failed to update iPXE template in DB") + return fmt.Errorf("failed to update iPXE template %q: %w", reported.Name, uerr) + } + } + + // Ensure an ITSA exists for (template, site). + if _, present := existingITSAByTemplateID[templateID]; !present { + if _, cerr := itsaDAO.Create(ctx, nil, cdbm.IpxeTemplateSiteAssociationCreateInput{ + IpxeTemplateID: templateID, + SiteID: siteID, + }); cerr != nil { + logger.Error().Err(cerr).Str("Name", reported.Name).Msg("Failed to create iPXE template site association") + return fmt.Errorf("failed to associate iPXE template %q with site: %w", reported.Name, cerr) + } + } + } + + // Reconcile deletions only on the final page of an inventory run. + if inventory.InventoryPage == nil || inventory.InventoryPage.TotalPages == 0 || + inventory.InventoryPage.CurrentPage == inventory.InventoryPage.TotalPages { + for _, existing := range existingITSAs { + if reportedTemplateIDs[existing.IpxeTemplateID] { + continue + } + templateName := "" + if existing.IpxeTemplate != nil { + templateName = existing.IpxeTemplate.Name + } + logger.Info(). + Str("Name", templateName). + Str("TemplateID", existing.IpxeTemplateID.String()). + Msg("Removing iPXE template site association since it is no longer reported by Site Controller") + if derr := itsaDAO.Delete(ctx, nil, existing.ID); derr != nil { + logger.Error().Err(derr).Str("Name", templateName).Msg("Failed to delete iPXE template site association from DB") + return fmt.Errorf("failed to delete iPXE template site association for %q: %w", templateName, derr) + } + + // If no other site references this template anymore, hard-delete + // the global template row. + _, count, gerr := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{IpxeTemplateIDs: []uuid.UUID{existing.IpxeTemplateID}}, + cdbp.PageInput{Limit: cutil.GetPtr(1)}, + nil, + ) + if gerr != nil { + logger.Error().Err(gerr).Str("TemplateID", existing.IpxeTemplateID.String()). + Msg("Failed to count remaining site associations for iPXE template") + return fmt.Errorf("failed to count site associations for iPXE template %q: %w", templateName, gerr) + } + if count == 0 { + if derr := templateDAO.Delete(ctx, nil, existing.IpxeTemplateID); derr != nil { + logger.Error().Err(derr).Str("Name", templateName).Msg("Failed to delete global iPXE template from DB") + return fmt.Errorf("failed to delete iPXE template %q: %w", templateName, derr) + } + } + } + } + + logger.Info().Msg("Completed activity") + return nil +} + +// NewManageIpxeTemplate returns a new ManageIpxeTemplate activity +func NewManageIpxeTemplate(dbSession *cdb.Session, siteClientPool *sc.ClientPool) ManageIpxeTemplate { + return ManageIpxeTemplate{ + dbSession: dbSession, + siteClientPool: siteClientPool, + } +} + +// ipxeScopeToString converts the IpxeTemplateScope enum from the gRPC proto +// to the lowercase string representation stored in the database. +func ipxeScopeToString(scope cwssaws.IpxeTemplateScope) string { + if scope == cwssaws.IpxeTemplateScope_PUBLIC { + return cdbm.IpxeTemplateScopePublic + } + return cdbm.IpxeTemplateScopeInternal +} diff --git a/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go new file mode 100644 index 0000000000..7a3760b17a --- /dev/null +++ b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go @@ -0,0 +1,434 @@ +/* + * 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 ipxetemplate + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cdbp "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" + + "github.com/NVIDIA/infra-controller/rest-api/workflow/internal/config" + cwu "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/util" + + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" +) + +// templatesForSite returns the global iPXE templates currently associated with the +// given site, via the IpxeTemplateSiteAssociation table. +func templatesForSite(t *testing.T, dbSession *cdb.Session, siteID uuid.UUID) []cdbm.IpxeTemplate { + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + rows, _, err := itsaDAO.GetAll(context.Background(), nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{SiteIDs: []uuid.UUID{siteID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + []string{cdbm.IpxeTemplateRelationName}, + ) + assert.NoError(t, err) + out := make([]cdbm.IpxeTemplate, 0, len(rows)) + for _, r := range rows { + if r.IpxeTemplate != nil { + out = append(out, *r.IpxeTemplate) + } + } + return out +} + +func TestManageIpxeTemplate_Reconcile_CreateUpdateDelete(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site := cwu.TestBuildSite(t, dbSession, ip, "test-site", cdbm.SiteStatusRegistered, nil, ipu) + assert.NotNil(t, site) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + + // Stable template IDs (matching core) + kernelInitrdID := uuid.MustParse("c4b1d4f6-69ba-5f55-90cd-ab2acd002475") + ubuntuAutoinstallID := uuid.MustParse("a7850943-e3cd-5e9a-93ca-9e12f52939cc") + + // 1) Create: inventory with two PUBLIC templates + inv1 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: kernelInitrdID.String()}, Name: "kernel-initrd", Scope: cwssaws.IpxeTemplateScope_PUBLIC, RequiredParams: []string{"p1"}, ReservedParams: []string{"r1"}, RequiredArtifacts: []string{"kernel"}}, + {Id: &cwssaws.IpxeTemplateId{Value: ubuntuAutoinstallID.String()}, Name: "ubuntu-autoinstall", Scope: cwssaws.IpxeTemplateScope_PUBLIC, RequiredParams: []string{}, ReservedParams: []string{}, RequiredArtifacts: []string{"iso"}}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv1)) + + templates := templatesForSite(t, dbSession, site.ID) + assert.Len(t, templates, 2) + nameSet := map[string]bool{} + for _, tmpl := range templates { + nameSet[tmpl.Name] = true + } + assert.True(t, nameSet["kernel-initrd"]) + assert.True(t, nameSet["ubuntu-autoinstall"]) + + // 2) Update: change required params of "ubuntu-autoinstall" (still PUBLIC) + inv2 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: kernelInitrdID.String()}, Name: "kernel-initrd", Scope: cwssaws.IpxeTemplateScope_PUBLIC, RequiredParams: []string{"p1"}, ReservedParams: []string{"r1"}, RequiredArtifacts: []string{"kernel"}}, + {Id: &cwssaws.IpxeTemplateId{Value: ubuntuAutoinstallID.String()}, Name: "ubuntu-autoinstall", Scope: cwssaws.IpxeTemplateScope_PUBLIC, RequiredParams: []string{"new-param"}, ReservedParams: []string{}, RequiredArtifacts: []string{"iso"}}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv2)) + + updated, err := templateDAO.Get(ctx, nil, ubuntuAutoinstallID) + assert.NoError(t, err) + assert.Equal(t, []string{"new-param"}, updated.RequiredParams) + + // 3) Delete: remove "ubuntu-autoinstall" from inventory + inv3 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: kernelInitrdID.String()}, Name: "kernel-initrd", Scope: cwssaws.IpxeTemplateScope_PUBLIC, RequiredParams: []string{"p1"}, ReservedParams: []string{"r1"}, RequiredArtifacts: []string{"kernel"}}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv3)) + + templates = templatesForSite(t, dbSession, site.ID) + assert.Len(t, templates, 1) + + // The global ubuntu-autoinstall row should also be gone (no other site + // references it). + _, err = templateDAO.Get(ctx, nil, ubuntuAutoinstallID) + assert.ErrorIs(t, err, cdb.ErrDoesNotExist) +} + +func TestManageIpxeTemplate_InternalScopeFiltered(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site := cwu.TestBuildSite(t, dbSession, ip, "test-site", cdbm.SiteStatusRegistered, nil, ipu) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + + publicID := uuid.MustParse("c4b1d4f6-69ba-5f55-90cd-ab2acd002475") + internalID := uuid.MustParse("a7850943-e3cd-5e9a-93ca-9e12f52939cc") + + inv := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: publicID.String()}, Name: "public-tmpl", Scope: cwssaws.IpxeTemplateScope_PUBLIC}, + {Id: &cwssaws.IpxeTemplateId{Value: internalID.String()}, Name: "internal-tmpl", Scope: cwssaws.IpxeTemplateScope_INTERNAL}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv)) + + templates := templatesForSite(t, dbSession, site.ID) + assert.Len(t, templates, 1) + + tmpl, err := templateDAO.Get(ctx, nil, publicID) + assert.NoError(t, err) + assert.Equal(t, cdbm.IpxeTemplateScopePublic, tmpl.Scope) + + _, err = templateDAO.Get(ctx, nil, internalID) + assert.ErrorIs(t, err, cdb.ErrDoesNotExist) +} + +func TestManageIpxeTemplate_InternalScopeDeletesExistingPublic(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site := cwu.TestBuildSite(t, dbSession, ip, "test-site", cdbm.SiteStatusRegistered, nil, ipu) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + + templateID := uuid.MustParse("c4b1d4f6-69ba-5f55-90cd-ab2acd002475") + + // First sync: template is PUBLIC + inv1 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: templateID.String()}, Name: "my-template", Scope: cwssaws.IpxeTemplateScope_PUBLIC}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv1)) + _, err := templateDAO.Get(ctx, nil, templateID) + assert.NoError(t, err) + + // Second sync: template changed to INTERNAL — should be removed via reconciliation + inv2 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: templateID.String()}, Name: "my-template", Scope: cwssaws.IpxeTemplateScope_INTERNAL}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv2)) + + templates := templatesForSite(t, dbSession, site.ID) + assert.Len(t, templates, 0) + _, err = templateDAO.Get(ctx, nil, templateID) + assert.ErrorIs(t, err, cdb.ErrDoesNotExist) +} + +func TestManageIpxeTemplate_CrossSiteNameConflict(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site1 := cwu.TestBuildSite(t, dbSession, ip, "site-1", cdbm.SiteStatusRegistered, nil, ipu) + site2 := cwu.TestBuildSite(t, dbSession, ip, "site-2", cdbm.SiteStatusRegistered, nil, ipu) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + + sharedTemplateID := uuid.MustParse("c4b1d4f6-69ba-5f55-90cd-ab2acd002475") + + // Site 1 reports template with name "kernel-initrd" + inv1 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: sharedTemplateID.String()}, Name: "kernel-initrd", Scope: cwssaws.IpxeTemplateScope_PUBLIC}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site1.ID, inv1)) + + // Site 2 reports same template ID but different name — should be skipped + // (no ITSA created for site2, global row keeps the original name). + inv2 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: sharedTemplateID.String()}, Name: "wrong-name", Scope: cwssaws.IpxeTemplateScope_PUBLIC}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site2.ID, inv2)) + + site2Templates := templatesForSite(t, dbSession, site2.ID) + assert.Len(t, site2Templates, 0) + + tmpl, err := templateDAO.Get(ctx, nil, sharedTemplateID) + assert.NoError(t, err) + assert.Equal(t, "kernel-initrd", tmpl.Name) + + // Site 2 now reports same template ID with the consistent name — should succeed + inv3 := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: sharedTemplateID.String()}, Name: "kernel-initrd", Scope: cwssaws.IpxeTemplateScope_PUBLIC}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site2.ID, inv3)) + + site2Templates = templatesForSite(t, dbSession, site2.ID) + assert.Len(t, site2Templates, 1) +} + +func TestManageIpxeTemplate_InventoryStatusFailed_Skip(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site := cwu.TestBuildSite(t, dbSession, ip, "test-site", cdbm.SiteStatusRegistered, nil, ipu) + + // Seed one template + ITSA + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + tmpl, err := templateDAO.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ + ID: uuid.New(), + Name: "existing-template", + Scope: cdbm.IpxeTemplateScopePublic, + }) + assert.NoError(t, err) + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + _, err = itsaDAO.Create(ctx, nil, cdbm.IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl.ID, SiteID: site.ID}) + assert.NoError(t, err) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + + // Send a failed inventory — nothing should change + inv := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_FAILED, + Templates: []*cwssaws.IpxeTemplate{{Id: &cwssaws.IpxeTemplateId{Value: uuid.NewString()}, Name: "other-template", Scope: cwssaws.IpxeTemplateScope_PUBLIC}}, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv)) + + templates := templatesForSite(t, dbSession, site.ID) + assert.Len(t, templates, 1) +} + +func TestManageIpxeTemplate_NilInventory(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site := cwu.TestBuildSite(t, dbSession, ip, "test-site", cdbm.SiteStatusRegistered, nil, ipu) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + + err := mit.UpdateIpxeTemplatesInDB(ctx, site.ID, nil) + assert.Error(t, err) +} + +func TestManageIpxeTemplate_EmptyInventory_DeletesAll(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site := cwu.TestBuildSite(t, dbSession, ip, "test-site", cdbm.SiteStatusRegistered, nil, ipu) + + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(dbSession) + for _, name := range []string{"tmpl-a", "tmpl-b"} { + tmpl, err := templateDAO.Create(ctx, nil, cdbm.IpxeTemplateCreateInput{ID: uuid.New(), Name: name, Scope: cdbm.IpxeTemplateScopePublic}) + assert.NoError(t, err) + _, err = itsaDAO.Create(ctx, nil, cdbm.IpxeTemplateSiteAssociationCreateInput{IpxeTemplateID: tmpl.ID, SiteID: site.ID}) + assert.NoError(t, err) + } + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + + inv := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{}, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site.ID, inv)) + + templates := templatesForSite(t, dbSession, site.ID) + assert.Len(t, templates, 0) +} + +func TestManageIpxeTemplate_UnknownSite(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + + inv := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{{Id: &cwssaws.IpxeTemplateId{Value: uuid.NewString()}, Name: "kernel-initrd", Scope: cwssaws.IpxeTemplateScope_PUBLIC}}, + } + err := mit.UpdateIpxeTemplatesInDB(ctx, uuid.New(), inv) + assert.Error(t, err) +} + +// TestManageIpxeTemplate_GlobalRowSurvivesWhileOtherSiteRefs verifies that the +// global ipxe_template row is only deleted when no ITSA references it. Two sites +// share a template; when site 1 stops reporting it, the global row must remain +// because site 2 still reports it. +func TestManageIpxeTemplate_GlobalRowSurvivesWhileOtherSiteRefs(t *testing.T) { + ctx := context.Background() + _ = config.GetTestConfig() + + dbSession := cwu.TestInitDB(t) + defer dbSession.Close() + cwu.TestSetupSchema(t, dbSession) + + ipOrg := "test-ip-org" + ipRoles := []string{"FORGE_PROVIDER_ADMIN"} + ipu := cwu.TestBuildUser(t, dbSession, uuid.NewString(), []string{ipOrg}, ipRoles) + ip := cwu.TestBuildInfrastructureProvider(t, dbSession, "test-provider", ipOrg, ipu) + site1 := cwu.TestBuildSite(t, dbSession, ip, "site-1", cdbm.SiteStatusRegistered, nil, ipu) + site2 := cwu.TestBuildSite(t, dbSession, ip, "site-2", cdbm.SiteStatusRegistered, nil, ipu) + + mit := NewManageIpxeTemplate(dbSession, cwu.TestTemporalSiteClientPool(t)) + templateDAO := cdbm.NewIpxeTemplateDAO(dbSession) + + templateID := uuid.MustParse("c4b1d4f6-69ba-5f55-90cd-ab2acd002475") + + // Both sites report the same template + inv := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + Templates: []*cwssaws.IpxeTemplate{ + {Id: &cwssaws.IpxeTemplateId{Value: templateID.String()}, Name: "shared", Scope: cwssaws.IpxeTemplateScope_PUBLIC}, + }, + } + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site1.ID, inv)) + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site2.ID, inv)) + + // Site 1 stops reporting it + emptyInv := &cwssaws.IpxeTemplateInventory{InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS} + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site1.ID, emptyInv)) + + // Global row must still exist (site 2 still references it) + _, err := templateDAO.Get(ctx, nil, templateID) + assert.NoError(t, err) + assert.Len(t, templatesForSite(t, dbSession, site1.ID), 0) + assert.Len(t, templatesForSite(t, dbSession, site2.ID), 1) + + // Site 2 also stops reporting it — global row should now be gone + assert.NoError(t, mit.UpdateIpxeTemplatesInDB(ctx, site2.ID, emptyInv)) + _, err = templateDAO.Get(ctx, nil, templateID) + assert.ErrorIs(t, err, cdb.ErrDoesNotExist) +} diff --git a/rest-api/workflow/pkg/activity/operatingsystem/operatingsystem.go b/rest-api/workflow/pkg/activity/operatingsystem/operatingsystem.go index 60868bef88..28b2f4d397 100644 --- a/rest-api/workflow/pkg/activity/operatingsystem/operatingsystem.go +++ b/rest-api/workflow/pkg/activity/operatingsystem/operatingsystem.go @@ -6,8 +6,10 @@ package operatingsystem import ( "context" "database/sql" + "errors" "time" + mapset "github.com/deckarep/golang-set/v2" "github.com/google/uuid" "github.com/rs/zerolog/log" @@ -19,7 +21,7 @@ import ( cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" - cwutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" ) const ( @@ -37,7 +39,7 @@ var ( } ) -// ManageOsImage is an activity wrapper for managing Image based OS lifecycle for a Site and allows +// ManageOsImage is an activity wrapper for managing Operating System lifecycle for a Site and allows // injecting DB access type ManageOsImage struct { dbSession *cdb.Session @@ -47,12 +49,12 @@ type ManageOsImage struct { // Activity functions // UpdateOsImagesInDB takes information pushed by Site Agent for a collection of image based OSs associated with the Site and updates the DB -func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UUID, osImageInventory *cwssaws.OsImageInventory) ([]uuid.UUID, error) { +func (mos ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UUID, osImageInventory *cwssaws.OsImageInventory) ([]uuid.UUID, error) { logger := log.With().Str("Activity", "UpdateOsImagesInDB").Str("Site ID", siteID.String()).Logger() logger.Info().Msg("starting activity") - stDAO := cdbm.NewSiteDAO(mskg.dbSession) + stDAO := cdbm.NewSiteDAO(mos.dbSession) site, err := stDAO.GetByID(ctx, nil, siteID, nil, false) if err != nil { @@ -69,7 +71,7 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU return nil, nil } - ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mskg.dbSession) + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mos.dbSession) existingOssas, _, err := ossaDAO.GetAll( ctx, @@ -77,7 +79,7 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU cdbm.OperatingSystemSiteAssociationFilterInput{ SiteIDs: []uuid.UUID{site.ID}, }, - cdbp.PageInput{Limit: cwutil.GetPtr(cdbp.TotalLimit)}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, []string{cdbm.OperatingSystemRelationName}, ) if err != nil { @@ -134,7 +136,7 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU nil, cdbm.OperatingSystemSiteAssociationUpdateInput{ OperatingSystemSiteAssociationID: ossa.ID, - IsMissingOnSite: cwutil.GetPtr(false), + IsMissingOnSite: cutil.GetPtr(false), }, ) if serr != nil { @@ -158,23 +160,23 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU switch controllerOsImage.Status { case cwssaws.OsImageStatus_ImageInProgress, cwssaws.OsImageStatus_ImageUninitialized, cwssaws.OsImageStatus_ImageDisabled: - ossaStatusMessage = cwutil.GetPtr("OS Image is still syncing") + ossaStatusMessage = cutil.GetPtr("OS Image is still syncing") case cwssaws.OsImageStatus_ImageReady: ossaStatus = cdbm.OperatingSystemSiteAssociationStatusSynced - ossaStatusMessage = cwutil.GetPtr("OS Image is ready to use") + ossaStatusMessage = cutil.GetPtr("OS Image is ready to use") case cwssaws.OsImageStatus_ImageFailed: ossaStatus = cdbm.OperatingSystemSiteAssociationStatusError if ossaStatusMessage == nil || *ossaStatusMessage == "" { - ossaStatusMessage = cwutil.GetPtr("OS Image failed to sync on Site") + ossaStatusMessage = cutil.GetPtr("OS Image failed to sync on Site") } } // if determined status is different that current // only that case update if ossaStatus != ossa.Status { - serr := mskg.updateOperatingSystemSiteAssociationStatusInDB(ctx, nil, ossa.ID, cwutil.GetPtr(ossaStatus), ossaStatusMessage) + serr := mos.updateOperatingSystemSiteAssociationStatusInDB(ctx, nil, ossa.ID, cutil.GetPtr(ossaStatus), ossaStatusMessage) if serr != nil { - slogger.Error().Err(serr).Msg("failed to update OS Image Site Association status detail in DB") + slogger.Error().Err(err).Msg("failed to update OS Image Site Association status detail in DB") } updatedOperatingSystemMap[ossa.OperatingSystemID] = true } @@ -209,13 +211,13 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU continue } // Trigger re-evaluation of Operating System status (delete if no association exists) - serr = mskg.UpdateOperatingSystemStatusInDB(ctx, ossa.OperatingSystemID) + serr = mos.UpdateOperatingSystemStatusInDB(ctx, ossa.OperatingSystemID) if serr != nil { - slogger.Error().Err(serr).Msg("failed to trigger Operating System status update in DB") + slogger.Error().Err(err).Msg("failed to trigger Operating System status update in DB") } } else { // Was this created within inventory receipt interval? If so, we may be processing an older inventory - if time.Since(ossa.Created) < cwutil.InventoryReceiptInterval { + if time.Since(ossa.Created) < cutil.InventoryReceiptInterval { continue } @@ -225,7 +227,7 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU nil, cdbm.OperatingSystemSiteAssociationUpdateInput{ OperatingSystemSiteAssociationID: ossa.ID, - IsMissingOnSite: cwutil.GetPtr(true), + IsMissingOnSite: cutil.GetPtr(true), }, ) if serr != nil { @@ -233,9 +235,9 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU continue } - serr = mskg.updateOperatingSystemSiteAssociationStatusInDB(ctx, nil, ossa.ID, cwutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusError), cwutil.GetPtr("Operating System is missing on Site")) + serr = mos.updateOperatingSystemSiteAssociationStatusInDB(ctx, nil, ossa.ID, cutil.GetPtr(cdbm.OperatingSystemSiteAssociationStatusError), cutil.GetPtr("Operating System is missing on Site")) if serr != nil { - slogger.Error().Err(serr).Msg("failed to update Operating System Site Association status detail in DB") + slogger.Error().Err(err).Msg("failed to update Operating System Site Association status detail in DB") } updatedOperatingSystemMap[ossa.OperatingSystemID] = true @@ -251,9 +253,9 @@ func (mskg ManageOsImage) UpdateOsImagesInDB(ctx context.Context, siteID uuid.UU } // updateOperatingSystemSiteAssociationStatusInDB is helper function to write OperatingSystemSiteAssociation updates to DB -func (mskg ManageOsImage) updateOperatingSystemSiteAssociationStatusInDB(ctx context.Context, tx *cdb.Tx, ossaID uuid.UUID, status *string, statusMessage *string) error { +func (mos ManageOsImage) updateOperatingSystemSiteAssociationStatusInDB(ctx context.Context, tx *cdb.Tx, ossaID uuid.UUID, status *string, statusMessage *string) error { if status != nil { - ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mskg.dbSession) + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mos.dbSession) _, err := ossaDAO.Update( ctx, @@ -267,7 +269,7 @@ func (mskg ManageOsImage) updateOperatingSystemSiteAssociationStatusInDB(ctx con return err } - statusDetailDAO := cdbm.NewStatusDetailDAO(mskg.dbSession) + statusDetailDAO := cdbm.NewStatusDetailDAO(mos.dbSession) _, err = statusDetailDAO.CreateFromParams(ctx, tx, ossaID.String(), *status, statusMessage) if err != nil { return err @@ -277,12 +279,12 @@ func (mskg ManageOsImage) updateOperatingSystemSiteAssociationStatusInDB(ctx con } // UpdateOperatingSystemStatusInDB is helper function to write Operating System updates to DB -func (mskg ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, osID uuid.UUID) error { +func (mos ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, osID uuid.UUID) error { logger := log.With().Str("Activity", "UpdateOperatingSystemStatusInDB").Str("Operating System ID", osID.String()).Logger() logger.Info().Msg("starting activity") - osDAO := cdbm.NewOperatingSystemDAO(mskg.dbSession) + osDAO := cdbm.NewOperatingSystemDAO(mos.dbSession) os, err := osDAO.GetByID(ctx, nil, osID, nil) if err != nil { @@ -299,14 +301,14 @@ func (mskg ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, o var osStatus *string var osMessage *string - ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mskg.dbSession) + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mos.dbSession) ossas, ossaTotal, err := ossaDAO.GetAll( ctx, nil, cdbm.OperatingSystemSiteAssociationFilterInput{ OperatingSystemIDs: []uuid.UUID{osID}, }, - cdbp.PageInput{Limit: cwutil.GetPtr(cdbp.TotalLimit)}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil, ) if err != nil { @@ -318,7 +320,7 @@ func (mskg ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, o if os.Status == cdbm.OperatingSystemStatusDeleting { if ossaTotal == 0 { // Start a db tx - tx, err := cdb.BeginTx(ctx, mskg.dbSession, &sql.TxOptions{}) + tx, err := cdb.BeginTx(ctx, mos.dbSession, &sql.TxOptions{}) if err != nil { logger.Error().Err(err).Msg("failed to start transaction") return err @@ -351,8 +353,8 @@ func (mskg ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, o if os.Status == cdbm.OperatingSystemStatusReady { return nil } - osStatus = cwutil.GetPtr(cdbm.OperatingSystemStatusReady) - osMessage = cwutil.GetPtr("Operating System successfully synced to all Sites") + osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusReady) + osMessage = cutil.GetPtr("Operating System successfully synced to all Sites") } else { statusCountMap := map[string]int{} for _, dbossa := range ossas { @@ -363,20 +365,20 @@ func (mskg ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, o if os.Status == cdbm.OperatingSystemStatusError { return nil } - osStatus = cwutil.GetPtr(cdbm.OperatingSystemStatusError) - osMessage = cwutil.GetPtr("Failed to sync Operating System to one or more Sites") + osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusError) + osMessage = cutil.GetPtr("Failed to sync Operating System to one or more Sites") } else if statusCountMap[cdbm.OperatingSystemSiteAssociationStatusSyncing] > 0 { if os.Status == cdbm.OperatingSystemStatusSyncing { return nil } - osStatus = cwutil.GetPtr(cdbm.OperatingSystemStatusSyncing) - osMessage = cwutil.GetPtr("Operating System syncing to one or more Sites") + osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusSyncing) + osMessage = cutil.GetPtr("Operating System syncing to one or more Sites") } else { if os.Status == cdbm.OperatingSystemStatusReady { return nil } - osStatus = cwutil.GetPtr(cdbm.OperatingSystemStatusReady) - osMessage = cwutil.GetPtr("Operating System successfully synced to all Sites") + osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusReady) + osMessage = cutil.GetPtr("Operating System successfully synced to all Sites") } } @@ -393,7 +395,7 @@ func (mskg ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, o return err } - statusDetailDAO := cdbm.NewStatusDetailDAO(mskg.dbSession) + statusDetailDAO := cdbm.NewStatusDetailDAO(mos.dbSession) _, err = statusDetailDAO.CreateFromParams(ctx, nil, osID.String(), *osStatus, osMessage) if err != nil { return err @@ -404,6 +406,408 @@ func (mskg ManageOsImage) UpdateOperatingSystemStatusInDB(ctx context.Context, o return nil } +// UpdateOperatingSystemsInDB reconciles the operating_system table for a Site based on Operating Systems reported from Site +func (mos ManageOsImage) UpdateOperatingSystemsInDB(ctx context.Context, siteID uuid.UUID, inventory *cwssaws.OperatingSystemInventory) error { + logger := log.With().Str("Activity", "UpdateOperatingSystemsInDB").Str("Site ID", siteID.String()).Logger() + logger.Info().Msg("Starting activity") + + if inventory == nil { + return errors.New("UpdateOperatingSystemsInDB called with nil inventory") + } + + if inventory.InventoryStatus == cwssaws.InventoryStatus_INVENTORY_STATUS_FAILED { + logger.Warn().Msg("Received failed inventory status from Site Agent, skipping") + return nil + } + + stDAO := cdbm.NewSiteDAO(mos.dbSession) + site, err := stDAO.GetByID(ctx, nil, siteID, nil, false) + if err != nil { + if errors.Is(err, cdb.ErrDoesNotExist) { + logger.Warn().Err(err).Msg("Received inventory for unknown or deleted Site") + } else { + logger.Error().Err(err).Msg("Failed to retrieve Site from DB") + } + return err + } + + // OSes that originate in nico-core are owned by the infrastructure provider, not by + // any individual tenant. We tag them with the site's InfrastructureProviderID so that + // ProviderAdmin can update them and all tenants of that provider can read them. + logger.Debug().Str("InfrastructureProviderID", site.InfrastructureProviderID.String()).Msg("Resolved Infrastructure Provider from Site") + + osDAO := cdbm.NewOperatingSystemDAO(mos.dbSession) + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mos.dbSession) + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(mos.dbSession) + + // Collect the UUIDs of all reported OS records (active only — the new Find APIs do not + // return deleted records). Site and REST share the same UUID as PK. + reportedOSIDs := mapset.NewSet[uuid.UUID]() + for _, reportedOS := range inventory.GetOperatingSystems() { + if reportedOS == nil { + logger.Error().Msg("Received nil OS record in inventory, skipping") + continue + } + + controllerOSID := reportedOS.GetId().GetValue() + if controllerOSID == "" { + logger.Error().Msg("Received OS record with empty ID, skipping") + continue + } + + reportedOSID, parseErr := uuid.Parse(controllerOSID) + if parseErr != nil { + logger.Error().Err(parseErr).Str("ControllerOperatingSystemID", controllerOSID).Msg("Received OS record with invalid UUID, skipping") + continue + } + reportedOSIDs.Add(reportedOSID) + } + + // Fetch DB records matching the reported IDs (including soft-deleted so we can detect + // the case where REST already deleted an OS that Site still reports active). + existingOSes, _, err := osDAO.GetAll(ctx, nil, cdbm.OperatingSystemFilterInput{ + OperatingSystemIds: reportedOSIDs.ToSlice(), + IncludeDeleted: true, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + + if err != nil { + logger.Error().Err(err).Msg("Failed to get Operating Systems from DB") + return err + } + + existingOSByID := map[uuid.UUID]*cdbm.OperatingSystem{} + for i := range existingOSes { + existingOSByID[existingOSes[i].ID] = &existingOSes[i] + } + + // Track global/limited OS IDs that need aggregate status recomputation. + globalOrLimitedOSIDs := map[uuid.UUID]struct{}{} + + // Create or update OSes based on the Site inventory. + for _, reportedOS := range inventory.GetOperatingSystems() { + if reportedOS == nil || reportedOS.GetId().GetValue() == "" { + continue + } + + reportedOSID, parseErr := uuid.Parse(reportedOS.GetId().GetValue()) + if parseErr != nil { + continue + } + + slogger := logger.With().Str("ControllerOperatingSystemID", reportedOSID.String()).Logger() + + coreUpdated, _ := time.Parse(time.RFC3339, reportedOS.Updated) + + ipxeTemplateParams := []cdbm.OperatingSystemIpxeParameter{} + for _, param := range reportedOS.IpxeTemplateParameters { + ipxeTemplateParam := cdbm.OperatingSystemIpxeParameter{} + ipxeTemplateParam.FromProto(param) + ipxeTemplateParams = append(ipxeTemplateParams, ipxeTemplateParam) + } + + ipxeTemplateArtifacts := []cdbm.OperatingSystemIpxeArtifact{} + for _, artifact := range reportedOS.IpxeTemplateArtifacts { + ipxeTemplateArtifact := cdbm.OperatingSystemIpxeArtifact{} + ipxeTemplateArtifact.FromProto(artifact) + ipxeTemplateArtifacts = append(ipxeTemplateArtifacts, ipxeTemplateArtifact) + } + + osType := cdbm.OperatingSystemTypeFromProtoMap[reportedOS.Type] + if osType == "" { + slogger.Error().Str("Type", reportedOS.Type.String()).Msg("Received unknown OS type from Site, skipping") + continue + } + + existingOS, found := existingOSByID[reportedOSID] + if !found { + // Templated iPXE OS: verify the referenced template is available at this site + // before creating the OS record. Skip silently if not available. + if osType == cdbm.OperatingSystemTypeTemplatedIPXE && reportedOS.IpxeTemplateId != nil { + ipxeTemplateID, serr := uuid.Parse(reportedOS.IpxeTemplateId.GetValue()) + if serr != nil { + slogger.Error().Err(serr).Str("IpxeTemplateID", reportedOS.IpxeTemplateId.GetValue()).Msg("Invalid iPXE template UUID in Operating System, skipping") + continue + } + + _, serr = itsaDAO.GetByIpxeTemplateIDAndSiteID(ctx, nil, ipxeTemplateID, siteID, nil) + if serr != nil { + if errors.Is(serr, cdb.ErrDoesNotExist) { + slogger.Warn().Str("IpxeTemplateID", ipxeTemplateID.String()).Msg("iPXE Template Association does not exist for Site, skipping") + continue + } + slogger.Error().Err(serr).Msg("Failed to retrieve IpxeTemplateSiteAssociation, DB error") + continue + } + } + + // New OS from Site: Create it with Site's InfrastructureProviderID. + // OSes originating in Site are provider-owned (not tenant-owned) + // ProviderAdmin can update them and all Tenants of the Provider can retrieve them + // Scope is Local: the definition lives at a single site with bidirectional sync + + status := cdbm.OperatingSystemStatusFromProtoMap[reportedOS.Status] + if status == "" { + slogger.Warn().Str("Status", reportedOS.Status.String()).Msg("Received unknown status from Site, using `Syncing` as default") + status = cdbm.OperatingSystemStatusSyncing + } + + _, serr := osDAO.Create(ctx, nil, cdbm.OperatingSystemCreateInput{ + ID: reportedOSID, + Name: reportedOS.Name, + Org: site.Org, + TenantID: nil, + InfrastructureProviderID: &site.InfrastructureProviderID, + OsType: osType, + Description: reportedOS.Description, + UserData: reportedOS.UserData, + IpxeScript: reportedOS.IpxeScript, + AllowOverride: reportedOS.AllowOverride, + PhoneHomeEnabled: reportedOS.PhoneHomeEnabled, + IpxeTemplateId: cutil.GetPtr(reportedOS.IpxeTemplateId.GetValue()), + IpxeTemplateParameters: ipxeTemplateParams, + IpxeTemplateArtifacts: ipxeTemplateArtifacts, + IpxeOSHash: reportedOS.IpxeTemplateDefinitionHash, + IpxeOsScope: cutil.GetPtr(cdbm.OperatingSystemScopeLocal), + Status: status, + }) + if serr != nil { + slogger.Error().Err(serr).Msg("Failed to create Operating System, DB error") + continue + } + + if !reportedOS.IsActive { + // TODO: Allow creation of inactive OSes + _, serr := osDAO.Update(ctx, nil, cdbm.OperatingSystemUpdateInput{ + OperatingSystemId: reportedOSID, + IsActive: cutil.GetPtr(false), + }) + if serr != nil { + slogger.Error().Err(serr).Msg("Failed to set Operating System to inactive on creation") + continue + } + } + + // Create site association linking the OS to the reporting site. + ossaStatus := cdbm.OperatingSystemSiteAssociationStatusFromProtoMap[reportedOS.Status] + if ossaStatus == "" { + slogger.Warn().Str("Status", reportedOS.Status.String()).Msg("Received unknown status from Site, using `Syncing` as default") + ossaStatus = cdbm.OperatingSystemSiteAssociationStatusSyncing + } + + _, ossaErr := ossaDAO.Create(ctx, nil, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: reportedOSID, + SiteID: siteID, + Status: ossaStatus, + }) + if ossaErr != nil { + slogger.Error().Err(ossaErr).Msg("Failed to create site association for new OS") + continue + } + + // Newly-created OS: definition and per-site association have just been + // written with the reported state. Skip the existing-OS update path + // below (it dereferences existingOS which is nil here) and do not add + // to globalOrLimitedOSIDs because new records are always Local scope. + continue + } + + // REST layer has already soft-deleted this OS (user-initiated) + // Do not restore it even if Site still reports it as active (the delete push to Site may be in-flight) + if existingOS.Deleted != nil { + continue + } + + // Update or create the per-site association for every OS type. For + // Global/Limited, REST is the source of truth for the definition so we + // only record the Site's controller state and skip the definition update. + // For Local (provider-owned, from Site) we also fall through to update + // the definition below. nil scope is treated as Local for safety + // (legacy records before the backfill migration) + isLocalScope := existingOS.IpxeOsScope == nil || *existingOS.IpxeOsScope == cdbm.OperatingSystemScopeLocal + controllerState := cdbm.OperatingSystemStatusFromProtoMap[reportedOS.Status] + if controllerState == "" { + slogger.Warn().Str("Status", reportedOS.Status.String()).Msg("Received unknown status from Site, using `Syncing` as default") + controllerState = cdbm.OperatingSystemStatusSyncing + } + + ossaStatus := cdbm.OperatingSystemSiteAssociationStatusFromProtoMap[reportedOS.Status] + if ossaStatus == "" { + slogger.Warn().Str("Status", reportedOS.Status.String()).Msg("Received unknown status from Site, using `Syncing` as default") + ossaStatus = cdbm.OperatingSystemSiteAssociationStatusSyncing + } + + ossa, serr := ossaDAO.GetByOperatingSystemIDAndSiteID(ctx, nil, reportedOSID, siteID, nil) + if serr != nil { + if !errors.Is(serr, cdb.ErrDoesNotExist) { + slogger.Error().Err(serr).Msg("Failed to retrieve Operating System Site Association, DB error") + continue + } + + // Operating System Site Association is missing, create it + _, serr := ossaDAO.Create(ctx, nil, cdbm.OperatingSystemSiteAssociationCreateInput{ + OperatingSystemID: reportedOSID, + SiteID: siteID, + Status: ossaStatus, + ControllerState: &controllerState, + }) + if serr != nil { + slogger.Error().Err(serr).Msg("Failed to create Operating System Site Association") + continue + } + } else { + // Update existing Operating System Site Association + _, uerr := ossaDAO.Update(ctx, nil, cdbm.OperatingSystemSiteAssociationUpdateInput{ + OperatingSystemSiteAssociationID: ossa.ID, + Status: &ossaStatus, + ControllerState: &controllerState, + }) + if uerr != nil { + slogger.Error().Err(uerr).Msg("Failed to update Operating System Site Association") + continue + } + } + + // TODO: Is this correct? + if !isLocalScope { + globalOrLimitedOSIDs[reportedOSID] = struct{}{} + } + + // Operating System exists in both REST and Site; update the REST record only for + // Local-scoped OSes (Site is the source of truth for the definition). + // Global/Limited OSes are REST-owned: skip the definition update and rely solely on + // the aggregate status recomputation that runs at the end of this function. + // Backfill: older records may have been created with tenant_id set and no + // infrastructure_provider_id (before this ownership model was established). + needsProviderBackfill := isLocalScope && existingOS.InfrastructureProviderID == nil + needsOrgBackfill := isLocalScope && existingOS.Org == "" && site.Org != "" + needsIsActiveCorrection := isLocalScope && existingOS.IsActive != reportedOS.IsActive + needsTenantClear := isLocalScope && existingOS.TenantID != nil + + if isLocalScope && (coreUpdated.After(existingOS.Updated) || needsProviderBackfill || needsOrgBackfill || needsIsActiveCorrection || needsTenantClear) { + controllerState := cdbm.OperatingSystemStatusFromProtoMap[reportedOS.Status] + if controllerState == "" { + slogger.Warn().Str("Status", reportedOS.Status.String()).Msg("Received unknown status from Site, using `Syncing` as default") + controllerState = cdbm.OperatingSystemStatusSyncing + } + + var ipxeTemplateID *string + if reportedOS.IpxeTemplateId != nil { + ipxeTemplateID = cutil.GetPtr(reportedOS.IpxeTemplateId.GetValue()) + } + + updateInput := cdbm.OperatingSystemUpdateInput{ + OperatingSystemId: existingOS.ID, + Name: &reportedOS.Name, + Org: &site.Org, + TenantID: nil, + InfrastructureProviderID: &site.InfrastructureProviderID, + OsType: &osType, + Description: reportedOS.Description, + UserData: reportedOS.UserData, + IpxeScript: reportedOS.IpxeScript, + AllowOverride: &reportedOS.AllowOverride, + PhoneHomeEnabled: &reportedOS.PhoneHomeEnabled, + IsActive: &reportedOS.IsActive, + IpxeTemplateId: ipxeTemplateID, + IpxeTemplateParameters: &ipxeTemplateParams, + IpxeTemplateArtifacts: &ipxeTemplateArtifacts, + IpxeOSHash: reportedOS.IpxeTemplateDefinitionHash, + Status: &controllerState, + } + if _, uerr := osDAO.Update(ctx, nil, updateInput); uerr != nil { + slogger.Error().Err(uerr).Msg("Failed to update Operating System, DB error") + continue + } + // Backfill: if the record previously had a tenant_id (old ownership model), clear it. + // Provider-owned OSes must not have tenant_id set. + if existingOS.TenantID != nil { + _, cerr := osDAO.Clear(ctx, nil, cdbm.OperatingSystemClearInput{ + OperatingSystemId: existingOS.ID, + TenantID: true, + }) + if cerr != nil { + slogger.Error().Err(cerr).Msg("Failed to clear Tenant ID from Provider owned Operating System, DB error") + continue + } + } + } + } + + // Deletion propagation: Site's Find APIs return only active records, so any iPXE OS + // in our DB that is NOT in this inventory was deleted in nico-core. Soft-delete it here. + // Image-based OSes are not managed by this inventory, so we restrict to iPXE types only. + // Exception: global- and limited-scoped OSes are owned by REST and must not be + // deleted based on Site's inventory (Site is not their source of truth) + allIpxeOSes, _, err := osDAO.GetAll(ctx, nil, cdbm.OperatingSystemFilterInput{ + OsTypes: []string{cdbm.OperatingSystemTypeIPXE, cdbm.OperatingSystemTypeTemplatedIPXE}, + InfrastructureProviderID: &site.InfrastructureProviderID, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if err != nil { + logger.Error().Err(err).Msg("Failed to fetch iPXE Operating Systems from DB for deletion reconciliation") + return err + } + + for _, ipxeOS := range allIpxeOSes { + if ipxeOS.IpxeOsScope != nil && *ipxeOS.IpxeOsScope != cdbm.OperatingSystemScopeLocal { + continue + } + + slogger := logger.With().Str("OperatingSystemID", ipxeOS.ID.String()).Logger() + + if !reportedOSIDs.Contains(ipxeOS.ID) { + slogger.Info().Msg("Soft-deleting iPXE OS absent from Site inventory") + serr := osDAO.Delete(ctx, nil, ipxeOS.ID) + if serr != nil { + slogger.Error().Err(serr).Msg("Failed to soft-delete OS, DB error") + continue + } + } + } + + // Aggregate status for global/limited OSes from their per-site core statuses. + // Rule: If all Site Associations have `Ready` status then the Operating System is `Ready`. Otherwise, it is `Syncing`. + if len(globalOrLimitedOSIDs) > 0 { + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(mos.dbSession) + for osID := range globalOrLimitedOSIDs { + slogger := logger.With().Str("OperatingSystemID", osID.String()).Logger() + + ossas, _, serr := ossaDAO.GetAll(ctx, nil, cdbm.OperatingSystemSiteAssociationFilterInput{ + OperatingSystemIDs: []uuid.UUID{osID}, + }, cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + + if serr != nil { + slogger.Error().Err(serr).Msg("Failed to fetch Site Associations to determine Operating System status, DB error") + continue + } + + allReady := true + for _, ossa := range ossas { + if ossa.Status != cdbm.OperatingSystemSiteAssociationStatusSynced { + allReady = false + break + } + } + + aggregatedStatus := cdbm.OperatingSystemStatusSyncing + if allReady && len(ossas) > 0 { + aggregatedStatus = cdbm.OperatingSystemStatusReady + } + + _, serr = osDAO.Update(ctx, nil, cdbm.OperatingSystemUpdateInput{ + OperatingSystemId: osID, + Status: &aggregatedStatus, + }) + if serr != nil { + slogger.Error().Err(serr).Msg("Failed to update aggregate OS status, DB error") + } + } + } + + logger.Info().Msg("Completed activity") + + return nil +} + // NewManageOsImage returns a new ManageOsImage activity func NewManageOsImage(dbSession *cdb.Session, siteClientPool *sc.ClientPool) ManageOsImage { return ManageOsImage{ diff --git a/rest-api/workflow/pkg/util/testing.go b/rest-api/workflow/pkg/util/testing.go index 618d32e035..c537a2653e 100644 --- a/rest-api/workflow/pkg/util/testing.go +++ b/rest-api/workflow/pkg/util/testing.go @@ -93,6 +93,20 @@ func TestSetupSchema(t *testing.T, dbSession *cdb.Session) { // create Operating System Site Association table err = dbSession.DB.ResetModel(context.Background(), (*cdbm.OperatingSystemSiteAssociation)(nil)) assert.Nil(t, err) + // create IpxeTemplate table (UNIQUE(name) applied by migration in production) + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.IpxeTemplate)(nil)) + assert.Nil(t, err) + _, 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 (composite UNIQUE applied by migration in production) + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.IpxeTemplateSiteAssociation)(nil)) + assert.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") + 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) // create Machine table err = dbSession.DB.ResetModel(context.Background(), (*cdbm.Machine)(nil)) assert.Nil(t, err) diff --git a/rest-api/workflow/pkg/workflow/ipxetemplate/update.go b/rest-api/workflow/pkg/workflow/ipxetemplate/update.go new file mode 100644 index 0000000000..9d9ab6dd2e --- /dev/null +++ b/rest-api/workflow/pkg/workflow/ipxetemplate/update.go @@ -0,0 +1,82 @@ +/* + * 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 ipxetemplate + +import ( + "fmt" + "time" + + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" + cwm "github.com/NVIDIA/infra-controller/rest-api/workflow/internal/metrics" + ipxeTemplateActivity "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/activity/ipxetemplate" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +// UpdateIpxeTemplateInventory is a workflow called by the Site Agent to update iPXE template +// inventory for a Site +func UpdateIpxeTemplateInventory(ctx workflow.Context, siteID string, inventory *cwssaws.IpxeTemplateInventory) (err error) { + logger := log.With().Str("Workflow", "UpdateIpxeTemplateInventory").Str("Site ID", siteID).Logger() + + startTime := workflow.Now(ctx) + + logger.Info().Msg("Starting workflow") + + parsedSiteID, err := uuid.Parse(siteID) + if err != nil { + logger.Warn().Err(err).Msg(fmt.Sprintf("workflow triggered with invalid site ID: %s", siteID)) + return err + } + + retrypolicy := &temporal.RetryPolicy{ + InitialInterval: 5 * time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: 30 * time.Second, + MaximumAttempts: 2, + } + options := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + RetryPolicy: retrypolicy, + } + + ctx = workflow.WithActivityOptions(ctx, options) + + var templateManager ipxeTemplateActivity.ManageIpxeTemplate + + err = workflow.ExecuteActivity(ctx, templateManager.UpdateIpxeTemplatesInDB, parsedSiteID, inventory).Get(ctx, nil) + if err != nil { + logger.Warn().Err(err).Msg("Failed to execute activity: UpdateIpxeTemplatesInDB") + return err + } + + logger.Info().Msg("Completing workflow") + + // Record latency for this inventory call + var inventoryMetricsManager cwm.ManageInventoryMetrics + + err = workflow.ExecuteActivity(ctx, inventoryMetricsManager.RecordLatency, parsedSiteID, "UpdateIpxeTemplateInventory", err != nil, workflow.Now(ctx).Sub(startTime)).Get(ctx, nil) + if err != nil { + logger.Warn().Err(err).Msg("Failed to execute activity: RecordLatency") + } + + return nil +} diff --git a/rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go b/rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go new file mode 100644 index 0000000000..174a23a677 --- /dev/null +++ b/rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go @@ -0,0 +1,104 @@ +/* + * 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 ipxetemplate + +import ( + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + cwm "github.com/NVIDIA/infra-controller/rest-api/workflow/internal/metrics" + ipxeTemplateActivity "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/activity/ipxetemplate" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" + + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" +) + +type UpdateIpxeTemplateTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + + env *testsuite.TestWorkflowEnvironment +} + +func (s *UpdateIpxeTemplateTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *UpdateIpxeTemplateTestSuite) AfterTest(suiteName, testName string) { + s.env.AssertExpectations(s.T()) +} + +func (s *UpdateIpxeTemplateTestSuite) Test_UpdateIpxeTemplateInventory_Success() { + var templateManager ipxeTemplateActivity.ManageIpxeTemplate + var metricsManager cwm.ManageInventoryMetrics + + siteID := uuid.New() + inv := &cwssaws.IpxeTemplateInventory{Templates: []*cwssaws.IpxeTemplate{}} + + s.env.RegisterActivity(templateManager.UpdateIpxeTemplatesInDB) + s.env.OnActivity(templateManager.UpdateIpxeTemplatesInDB, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + s.env.RegisterActivity(metricsManager.RecordLatency) + s.env.OnActivity(metricsManager.RecordLatency, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(UpdateIpxeTemplateInventory, siteID.String(), inv) + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +func (s *UpdateIpxeTemplateTestSuite) Test_UpdateIpxeTemplateInventory_ActivityFails() { + var templateManager ipxeTemplateActivity.ManageIpxeTemplate + var metricsManager cwm.ManageInventoryMetrics + + siteID := uuid.New() + inv := &cwssaws.IpxeTemplateInventory{Templates: []*cwssaws.IpxeTemplate{}} + + s.env.RegisterActivity(templateManager.UpdateIpxeTemplatesInDB) + s.env.OnActivity(templateManager.UpdateIpxeTemplatesInDB, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("UpdateIpxeTemplatesInDB failure")) + + s.env.RegisterActivity(metricsManager.RecordLatency) + s.env.OnActivity(metricsManager.RecordLatency, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() + + s.env.ExecuteWorkflow(UpdateIpxeTemplateInventory, siteID.String(), inv) + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.NotNil(err) + + var applicationErr *temporal.ApplicationError + s.True(errors.As(err, &applicationErr)) + s.Equal("UpdateIpxeTemplatesInDB failure", applicationErr.Error()) +} + +func (s *UpdateIpxeTemplateTestSuite) Test_UpdateIpxeTemplateInventory_InvalidSiteID() { + inv := &cwssaws.IpxeTemplateInventory{Templates: []*cwssaws.IpxeTemplate{}} + + s.env.ExecuteWorkflow(UpdateIpxeTemplateInventory, "not-a-valid-uuid", inv) + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.NotNil(err) +} + +func TestUpdateIpxeTemplateTestSuite(t *testing.T) { + suite.Run(t, new(UpdateIpxeTemplateTestSuite)) +} diff --git a/rest-api/workflow/pkg/workflow/operatingsystem/update.go b/rest-api/workflow/pkg/workflow/operatingsystem/update.go index a541191f61..262e7e9714 100644 --- a/rest-api/workflow/pkg/workflow/operatingsystem/update.go +++ b/rest-api/workflow/pkg/workflow/operatingsystem/update.go @@ -8,6 +8,7 @@ import ( "time" cwm "github.com/NVIDIA/infra-controller/rest-api/workflow/internal/metrics" + osActivity "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/activity/operatingsystem" "github.com/google/uuid" "github.com/rs/zerolog/log" @@ -16,14 +17,13 @@ import ( "go.temporal.io/sdk/workflow" cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" - osImageActivity "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/activity/operatingsystem" ) // UpdateOsImageInventory is a workflow called by Site Agent to update image based Operating System for a Site func UpdateOsImageInventory(ctx workflow.Context, siteID string, osImageInventory *cwssaws.OsImageInventory) (err error) { logger := log.With().Str("Workflow", "UpdateOsImageInventory").Str("Site ID", siteID).Logger() - startTime := time.Now() + startTime := workflow.Now(ctx) logger.Info().Msg("starting workflow") @@ -34,7 +34,7 @@ func UpdateOsImageInventory(ctx workflow.Context, siteID string, osImageInventor } // RetryPolicy specifies how to automatically handle retries if an Activity fails. - retrypolicy := &temporal.RetryPolicy{ + retryPolicy := &temporal.RetryPolicy{ InitialInterval: 5 * time.Second, BackoffCoefficient: 2.0, MaximumInterval: 30 * time.Second, @@ -44,22 +44,22 @@ func UpdateOsImageInventory(ctx workflow.Context, siteID string, osImageInventor // Timeout options specify when to automatically timeout Activity functions. StartToCloseTimeout: 30 * time.Second, // Optionally provide a customized RetryPolicy. - RetryPolicy: retrypolicy, + RetryPolicy: retryPolicy, } ctx = workflow.WithActivityOptions(ctx, options) - var osImageManager osImageActivity.ManageOsImage + var osManager osActivity.ManageOsImage - var osImageIDs []uuid.UUID + var osIDs []uuid.UUID - err = workflow.ExecuteActivity(ctx, osImageManager.UpdateOsImagesInDB, parsedSiteID, osImageInventory).Get(ctx, &osImageIDs) + err = workflow.ExecuteActivity(ctx, osManager.UpdateOsImagesInDB, parsedSiteID, osImageInventory).Get(ctx, &osIDs) if err != nil { logger.Warn().Err(err).Msg("failed execute activity: UpdateOsImagesInDB") } else { - // Update the status of the OS images - for _, osImageID := range osImageIDs { - serr := workflow.ExecuteActivity(ctx, osImageManager.UpdateOperatingSystemStatusInDB, osImageID).Get(ctx, nil) + // Update the status of the corresponding Operating Systems + for _, osID := range osIDs { + serr := workflow.ExecuteActivity(ctx, osManager.UpdateOperatingSystemStatusInDB, osID).Get(ctx, nil) if serr != nil { // Log error but continue as we don't want to interrupt inventory processing logger.Warn().Err(serr).Msg("failed to execute activity: UpdateOperatingSystemStatusInDB") @@ -70,13 +70,69 @@ func UpdateOsImageInventory(ctx workflow.Context, siteID string, osImageInventor // Record latency for this inventory call var inventoryMetricsManager cwm.ManageInventoryMetrics - serr := workflow.ExecuteActivity(ctx, inventoryMetricsManager.RecordLatency, parsedSiteID, "UpdateOsImageInventory", err != nil, time.Since(startTime)).Get(ctx, nil) + serr := workflow.ExecuteActivity(ctx, inventoryMetricsManager.RecordLatency, parsedSiteID, "UpdateOsImageInventory", err != nil, workflow.Now(ctx).Sub(startTime)).Get(ctx, nil) if serr != nil { logger.Warn().Err(serr).Msg("failed to execute activity: RecordLatency") } logger.Info().Msg("completing workflow") - // Return original error from inventory activity, if any + return err +} + +// UpdateOperatingSystemInventory is a workflow called by the Site Agent to reconcile Operating Systems +// synced from nico-core into the operating_system table. +func UpdateOperatingSystemInventory(ctx workflow.Context, siteID string, inventory *cwssaws.OperatingSystemInventory) (err error) { + logger := log.With().Str("Workflow", "UpdateOperatingSystemInventory").Str("Site ID", siteID).Logger() + + startTime := workflow.Now(ctx) + + logger.Info().Msg("Starting workflow") + + parsedSiteID, err := uuid.Parse(siteID) + if err != nil { + logger.Warn().Err(err).Msgf("workflow triggered with invalid site ID: %s", siteID) + return err + } + + retryPolicy := &temporal.RetryPolicy{ + InitialInterval: 5 * time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: 30 * time.Second, + MaximumAttempts: 2, + } + options := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + RetryPolicy: retryPolicy, + } + ctx = workflow.WithActivityOptions(ctx, options) + + var osManager osActivity.ManageOsImage + + var osIDs []uuid.UUID + + // TODO: Return IDs for Operating Systems that were updated/needs processing + err = workflow.ExecuteActivity(ctx, osManager.UpdateOperatingSystemsInDB, parsedSiteID, inventory).Get(ctx, &osIDs) + if err != nil { + logger.Warn().Err(err).Msg("Failed to execute activity: UpdateOperatingSystemsInDB") + } else { + for _, osID := range osIDs { + serr := workflow.ExecuteActivity(ctx, osManager.UpdateOperatingSystemStatusInDB, osID).Get(ctx, nil) + if serr != nil { + logger.Warn().Err(serr).Msg("failed to execute activity: UpdateOperatingSystemStatusInDB") + } + } + } + + logger.Info().Msg("Completing workflow") + + // Record latency for this inventory call + var inventoryMetricsManager cwm.ManageInventoryMetrics + + serr := workflow.ExecuteActivity(ctx, inventoryMetricsManager.RecordLatency, parsedSiteID, "UpdateOperatingSystemInventory", err != nil, workflow.Now(ctx).Sub(startTime)).Get(ctx, nil) + if serr != nil { + logger.Warn().Err(serr).Msg("Failed to execute activity: RecordLatency") + } + return err } From 6e2327e7bc2bbe7f4c11453b7603d71190c626a0 Mon Sep 17 00:00:00 2001 From: Kyle Felter Date: Wed, 24 Jun 2026 00:52:58 -0500 Subject: [PATCH 3/5] chore: Use two-line SPDX license header Signed-off-by: Kyle Felter --- rest-api/db/pkg/db/model/ipxetemplate.go | 18 ++---------------- rest-api/db/pkg/db/model/ipxetemplate_test.go | 18 ++---------------- .../db/model/ipxetemplatesiteassociation.go | 18 ++---------------- .../model/ipxetemplatesiteassociation_test.go | 18 ++---------------- .../pkg/activity/ipxetemplate/ipxetemplate.go | 18 ++---------------- .../activity/ipxetemplate/ipxetemplate_test.go | 18 ++---------------- .../pkg/workflow/ipxetemplate/update.go | 18 ++---------------- .../pkg/workflow/ipxetemplate/update_test.go | 18 ++---------------- 8 files changed, 16 insertions(+), 128 deletions(-) diff --git a/rest-api/db/pkg/db/model/ipxetemplate.go b/rest-api/db/pkg/db/model/ipxetemplate.go index 18541dab28..aa06108924 100644 --- a/rest-api/db/pkg/db/model/ipxetemplate.go +++ b/rest-api/db/pkg/db/model/ipxetemplate.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package model diff --git a/rest-api/db/pkg/db/model/ipxetemplate_test.go b/rest-api/db/pkg/db/model/ipxetemplate_test.go index a6da4d4424..ebecb9d201 100644 --- a/rest-api/db/pkg/db/model/ipxetemplate_test.go +++ b/rest-api/db/pkg/db/model/ipxetemplate_test.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package model diff --git a/rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go index beb4f910af..2a5c69d97f 100644 --- a/rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go +++ b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package model diff --git a/rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go index 5fd19f4806..95e670b0fb 100644 --- a/rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go +++ b/rest-api/db/pkg/db/model/ipxetemplatesiteassociation_test.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package model diff --git a/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go index 104ceceb06..3055f108a9 100644 --- a/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go +++ b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package ipxetemplate diff --git a/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go index 7a3760b17a..957cca2b04 100644 --- a/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go +++ b/rest-api/workflow/pkg/activity/ipxetemplate/ipxetemplate_test.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package ipxetemplate diff --git a/rest-api/workflow/pkg/workflow/ipxetemplate/update.go b/rest-api/workflow/pkg/workflow/ipxetemplate/update.go index 9d9ab6dd2e..117eadf332 100644 --- a/rest-api/workflow/pkg/workflow/ipxetemplate/update.go +++ b/rest-api/workflow/pkg/workflow/ipxetemplate/update.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package ipxetemplate diff --git a/rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go b/rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go index 174a23a677..e7b639e66c 100644 --- a/rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go +++ b/rest-api/workflow/pkg/workflow/ipxetemplate/update_test.go @@ -1,19 +1,5 @@ -/* - * 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. - */ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 package ipxetemplate From 9ab775f9be7cf941c03811ad88ac2c94db93ab19 Mon Sep 17 00:00:00 2001 From: Kyle Felter Date: Wed, 24 Jun 2026 10:09:10 -0500 Subject: [PATCH 4/5] feat: Add Templated iPXE OS REST API, Core proxy push, and inbound inventory sync Signed-off-by: Kyle Felter --- rest-api/api/pkg/api/handler/instance.go | 22 ++ rest-api/api/pkg/api/handler/instancebatch.go | 11 + rest-api/api/pkg/api/handler/ipxetemplate.go | 367 +++++++++++++++++ .../api/pkg/api/handler/operatingsystem.go | 373 +++++++++++++++--- rest-api/api/pkg/api/model/ipxetemplate.go | 69 ++++ rest-api/api/pkg/api/model/operatingsystem.go | 337 +++++++++++++++- .../model/operatingsystem_templated_test.go | 215 ++++++++++ rest-api/api/pkg/api/routes.go | 11 + rest-api/api/pkg/api/routes_test.go | 1 + rest-api/openapi/spec.yaml | 233 +++++++++++ .../managers/operatingsystem/cron.go | 28 +- .../managers/operatingsystem/publisher.go | 28 ++ .../pkg/activity/ipxetemplate.go | 87 ++++ .../pkg/activity/operatingsystem.go | 84 ++++ .../pkg/workflow/ipxetemplate.go | 45 +++ .../pkg/workflow/operatingsystem.go | 31 ++ 16 files changed, 1878 insertions(+), 64 deletions(-) create mode 100644 rest-api/api/pkg/api/handler/ipxetemplate.go create mode 100644 rest-api/api/pkg/api/model/ipxetemplate.go create mode 100644 rest-api/api/pkg/api/model/operatingsystem_templated_test.go create mode 100644 rest-api/site-workflow/pkg/activity/ipxetemplate.go create mode 100644 rest-api/site-workflow/pkg/workflow/ipxetemplate.go diff --git a/rest-api/api/pkg/api/handler/instance.go b/rest-api/api/pkg/api/handler/instance.go index 716d417fd7..0d98c95178 100644 --- a/rest-api/api/pkg/api/handler/instance.go +++ b/rest-api/api/pkg/api/handler/instance.go @@ -207,6 +207,17 @@ func (cih CreateInstanceHandler) buildInstanceCreateRequestOsConfig(c echo.Conte }, UserData: apiRequest.UserData, }, osID, nil + } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + return &cwssaws.InstanceOperatingSystemConfig{ + RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + Variant: &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{ + Value: os.ID.String(), + }, + }, + UserData: apiRequest.UserData, + }, osID, nil } else { return &cwssaws.InstanceOperatingSystemConfig{ PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, @@ -2088,6 +2099,17 @@ func (uih UpdateInstanceHandler) buildInstanceUpdateRequestOsConfig(c echo.Conte }, UserData: userData, }, osID, nil + } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + return &cwssaws.InstanceOperatingSystemConfig{ + RunProvisioningInstructionsOnEveryBoot: alwaysBootWithCustomIpxe, + PhoneHomeEnabled: phoneHomeEnabled, + Variant: &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{ + Value: os.ID.String(), + }, + }, + UserData: userData, + }, osID, nil } else if os.Type == cdbm.OperatingSystemTypeImage { return &cwssaws.InstanceOperatingSystemConfig{ PhoneHomeEnabled: phoneHomeEnabled, diff --git a/rest-api/api/pkg/api/handler/instancebatch.go b/rest-api/api/pkg/api/handler/instancebatch.go index e47e33ff2e..f94907ee8a 100644 --- a/rest-api/api/pkg/api/handler/instancebatch.go +++ b/rest-api/api/pkg/api/handler/instancebatch.go @@ -186,6 +186,17 @@ func (bcih BatchCreateInstanceHandler) buildBatchInstanceCreateRequestOsConfig(c }, UserData: apiRequest.UserData, }, osID, nil + } else if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + return &cwssaws.InstanceOperatingSystemConfig{ + RunProvisioningInstructionsOnEveryBoot: *apiRequest.AlwaysBootWithCustomIpxe, + PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, + Variant: &cwssaws.InstanceOperatingSystemConfig_OperatingSystemId{ + OperatingSystemId: &cwssaws.OperatingSystemId{ + Value: os.ID.String(), + }, + }, + UserData: apiRequest.UserData, + }, osID, nil } else { return &cwssaws.InstanceOperatingSystemConfig{ PhoneHomeEnabled: *apiRequest.PhoneHomeEnabled, diff --git a/rest-api/api/pkg/api/handler/ipxetemplate.go b/rest-api/api/pkg/api/handler/ipxetemplate.go new file mode 100644 index 0000000000..5798f15673 --- /dev/null +++ b/rest-api/api/pkg/api/handler/ipxetemplate.go @@ -0,0 +1,367 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/attribute" + tclient "go.temporal.io/sdk/client" + + "github.com/NVIDIA/infra-controller/rest-api/api/internal/config" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/pagination" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cdbp "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/paginator" +) + +// ~~~~~ GetAll Handler ~~~~~ // + +// GetAllIpxeTemplateHandler is the API Handler for getting all iPXE templates +type GetAllIpxeTemplateHandler struct { + dbSession *cdb.Session + tc tclient.Client + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewGetAllIpxeTemplateHandler initializes and returns a new handler for getting all iPXE templates +func NewGetAllIpxeTemplateHandler(dbSession *cdb.Session, tc tclient.Client, cfg *config.Config) GetAllIpxeTemplateHandler { + return GetAllIpxeTemplateHandler{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Get all iPXE templates +// @Description Get all iPXE templates propagated from nico-core. Templates are global (one row per stable core template UUID); per-site availability is recorded internally. The `siteId` query parameter is optional and may be repeated to restrict results to templates available at one or more sites. When omitted, a Provider Admin/Viewer receives templates available at any site owned by their infrastructure provider; a Tenant Admin receives templates available at any site whose provider the tenant has a Tenant Account on. +// @Tags iPXE Template +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param siteId query []string false "Optional site ID(s); may be repeated to restrict results to templates available at any of the sites" +// @Param pageNumber query integer false "Page number of results returned" +// @Param pageSize query integer false "Number of results per page" +// @Param orderBy query string false "Order by field" +// @Success 200 {object} []model.APIIpxeTemplate +// @Router /v2/org/{org}/nico/ipxe-template [get] +func (h GetAllIpxeTemplateHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("IpxeTemplate", "GetAll", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate role (Provider Admin/Viewer or Tenant Admin) and org membership + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, h.dbSession, org, dbUser, true, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) + } + + // Parse optional siteId query parameters. Multiple values (repeated + // `?siteId=...&siteId=...`) are supported. + requestedSiteIDStrs := c.QueryParams()["siteId"] + requestedSiteIDs := make([]uuid.UUID, 0, len(requestedSiteIDStrs)) + for _, s := range requestedSiteIDStrs { + if s == "" { + continue + } + parsed, perr := uuid.Parse(s) + if perr != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid siteId in query parameter: %s", s), nil) + } + requestedSiteIDs = append(requestedSiteIDs, parsed) + } + + // Build the caller's authorized site set, tracking which sites come from the + // provider path vs the tenant path. A site can be in both sets for a + // dual-role caller — provider access wins (fewer restrictions). + providerSites := mapset.NewSet[uuid.UUID]() + tenantSites := mapset.NewSet[uuid.UUID]() + + if infrastructureProvider != nil { + siteDAO := cdbm.NewSiteDAO(h.dbSession) + sites, _, serr := siteDAO.GetAll(ctx, nil, + cdbm.SiteFilterInput{InfrastructureProviderIDs: []uuid.UUID{infrastructureProvider.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve provider sites, DB error", nil) + } + for i := range sites { + providerSites.Add(sites[i].ID) + } + } + + if tenant != nil { + tsDAO := cdbm.NewTenantSiteDAO(h.dbSession) + tss, _, terr := tsDAO.GetAll(ctx, nil, + cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenant.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant Site associations, DB error", nil) + } + for i := range tss { + tenantSites.Add(tss[i].SiteID) + } + } + + isAuthorized := func(id uuid.UUID) bool { + return providerSites.Contains(id) || tenantSites.Contains(id) + } + + // Determine the effective site filter: + // - siteId(s) provided: must all be authorized; use them as-is. + // - siteId(s) omitted: use the union of provider and tenant accessible sites. + var effectiveSiteIDs []uuid.UUID + if len(requestedSiteIDs) > 0 { + for _, id := range requestedSiteIDs { + if !isAuthorized(id) { + logger.Warn().Str("siteID", id.String()).Msg("org not authorized to access requested Site") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Current org is not authorized to access Site: %s", id.String()), nil) + } + } + effectiveSiteIDs = requestedSiteIDs + } else { + effectiveSiteIDs = providerSites.Union(tenantSites).ToSlice() + } + + // No authorized sites — neither provider-owned nor reachable via a tenant account. + if len(effectiveSiteIDs) == 0 { + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not associated with any Site", nil) + } + + // Validate pagination request + pageRequest := pagination.PageRequest{} + if err := c.Bind(&pageRequest); err != nil { + logger.Warn().Err(err).Msg("error binding pagination request data into API model") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request pagination data", nil) + } + if err := pageRequest.Validate(cdbm.IpxeTemplateOrderByFields); err != nil { + logger.Warn().Err(err).Msg("error validating pagination request data") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to validate pagination request data", err) + } + + // Resolve which template IDs are available at the authorized sites via + // the IpxeTemplateSiteAssociation table. + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(h.dbSession) + associations, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{SiteIDs: effectiveSiteIDs}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE template site associations, DB error", nil) + } + + templateIDSet := mapset.NewSet[uuid.UUID]() + for _, a := range associations { + templateIDSet.Add(a.IpxeTemplateID) + } + templateIDs := templateIDSet.ToSlice() + + templateDAO := cdbm.NewIpxeTemplateDAO(h.dbSession) + templates, total, err := templateDAO.GetAll( + ctx, + nil, + cdbm.IpxeTemplateFilterInput{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 cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE templates, DB error", nil) + } + + apiTemplates := []*model.APIIpxeTemplate{} + for i := range templates { + apiTemplates = append(apiTemplates, model.NewAPIIpxeTemplate(&templates[i])) + } + + pageResponse := pagination.NewPageResponse(*pageRequest.PageNumber, *pageRequest.PageSize, total, pageRequest.OrderByStr) + pageHeader, err := json.Marshal(pageResponse) + if err != nil { + logger.Error().Err(err).Msg("error marshaling pagination response") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to generate pagination response header", nil) + } + c.Response().Header().Set(pagination.ResponseHeaderName, string(pageHeader)) + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, apiTemplates) +} + +// ~~~~~ Get Handler ~~~~~ // + +// GetIpxeTemplateHandler is the API Handler for retrieving a single iPXE template +type GetIpxeTemplateHandler struct { + dbSession *cdb.Session + tc tclient.Client + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewGetIpxeTemplateHandler initializes and returns a new handler to retrieve an iPXE template +func NewGetIpxeTemplateHandler(dbSession *cdb.Session, tc tclient.Client, cfg *config.Config) GetIpxeTemplateHandler { + return GetIpxeTemplateHandler{ + dbSession: dbSession, + tc: tc, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Retrieve an iPXE template +// @Description Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is currently available (Provider Admin/Viewer for a Site owned by their infrastructure provider, or Tenant Admin with a Tenant Account on a Site reporting the template). +// @Tags iPXE Template +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization" +// @Param id path string true "Stable template ID (UUID from core)" +// @Success 200 {object} model.APIIpxeTemplate +// @Router /v2/org/{org}/nico/ipxe-template/{id} [get] +func (h GetIpxeTemplateHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("IpxeTemplate", "Get", c, h.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + logger.Error().Msg("invalid User object found in request context") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate role (Provider Admin/Viewer or Tenant Admin) — this also validates + // org membership, so no separate membership check is needed here. + infrastructureProvider, tenant, apiError := common.IsProviderOrTenant(ctx, logger, h.dbSession, org, dbUser, true, false) + if apiError != nil { + return cutil.NewAPIErrorResponse(c, apiError.Code, apiError.Message, apiError.Data) + } + + // Parse template ID from URL (this is the stable core template UUID, which is + // also the primary key in REST). + templateIDStr := c.Param("id") + templateID, err := uuid.Parse(templateIDStr) + if err != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid iPXE template ID: %s", templateIDStr), nil) + } + + logger = logger.With().Str("IpxeTemplate ID", templateIDStr).Logger() + h.tracerSpan.SetAttribute(handlerSpan, attribute.String("ipxe_template_id", templateIDStr), logger) + + templateDAO := cdbm.NewIpxeTemplateDAO(h.dbSession) + tmpl, err := templateDAO.Get(ctx, nil, templateID) + if err != nil { + if errors.Is(err, cdb.ErrDoesNotExist) { + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, fmt.Sprintf("Could not find iPXE template with ID: %s", templateIDStr), nil) + } + logger.Error().Err(err).Msg("error retrieving iPXE template from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve iPXE template, DB error", nil) + } + + // Authorization: caller must be associated (via provider ownership or tenant + // account) with at least one Site at which this template is currently + // reported. + itsaDAO := cdbm.NewIpxeTemplateSiteAssociationDAO(h.dbSession) + associations, _, err := itsaDAO.GetAll(ctx, nil, + cdbm.IpxeTemplateSiteAssociationFilterInput{IpxeTemplateIDs: []uuid.UUID{templateID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + nil, + ) + if err != nil { + logger.Error().Err(err).Msg("error retrieving iPXE template site associations") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify iPXE template authorization, DB error", nil) + } + + if !callerHasAccessToAnyAssociatedSite(ctx, logger, h.dbSession, infrastructureProvider, tenant, associations) { + logger.Warn().Msg("caller is not authorized to access any Site associated with this iPXE template") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Current org is not authorized to access this iPXE template", nil) + } + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, model.NewAPIIpxeTemplate(tmpl)) +} + +// callerHasAccessToAnyAssociatedSite returns true when the caller (provider or tenant) +// has access to at least one site in the given association set. +func callerHasAccessToAnyAssociatedSite( + ctx context.Context, + logger zerolog.Logger, + dbSession *cdb.Session, + provider *cdbm.InfrastructureProvider, + tenant *cdbm.Tenant, + associations []cdbm.IpxeTemplateSiteAssociation, +) bool { + if len(associations) == 0 { + return false + } + + siteIDs := make([]uuid.UUID, 0, len(associations)) + for _, a := range associations { + siteIDs = append(siteIDs, a.SiteID) + } + + // Provider path: any site owned by the caller's provider. + if provider != nil { + siteDAO := cdbm.NewSiteDAO(dbSession) + sites, _, serr := siteDAO.GetAll(ctx, nil, cdbm.SiteFilterInput{ + InfrastructureProviderIDs: []uuid.UUID{provider.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) + if serr != nil { + logger.Error().Err(serr).Msg("error retrieving provider sites for iPXE template authorization") + return false + } + if len(sites) > 0 { + return true + } + } + + // Tenant path: any site reachable via a TenantSite association. + if tenant != nil { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, terr := tsDAO.GetAll(ctx, nil, cdbm.TenantSiteFilterInput{ + TenantIDs: []uuid.UUID{tenant.ID}, + SiteIDs: siteIDs, + }, cdbp.PageInput{Limit: cutil.GetPtr(1)}, nil) + if terr != nil { + logger.Error().Err(terr).Msg("error retrieving Tenant Site associations for iPXE template authorization") + return false + } + if len(tss) > 0 { + return true + } + } + + return false +} diff --git a/rest-api/api/pkg/api/handler/operatingsystem.go b/rest-api/api/pkg/api/handler/operatingsystem.go index 8951c5fdd2..5d6bd395ee 100644 --- a/rest-api/api/pkg/api/handler/operatingsystem.go +++ b/rest-api/api/pkg/api/handler/operatingsystem.go @@ -14,10 +14,12 @@ import ( "go.opentelemetry.io/otel/attribute" temporalClient "go.temporal.io/sdk/client" tp "go.temporal.io/sdk/temporal" + "google.golang.org/protobuf/proto" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/google/uuid" "github.com/labstack/echo/v4" + "github.com/rs/zerolog" "github.com/NVIDIA/infra-controller/rest-api/api/internal/config" "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" @@ -34,6 +36,124 @@ import ( "github.com/NVIDIA/infra-controller/rest-api/workflow/pkg/queue" ) +// NICo Core (forge.Forge) Operating System methods proxied for iPXE / Templated +// iPXE Operating Systems via the generic Core gRPC proxy (epic #1927). Image-type +// Operating Systems continue to use the dedicated OsImage site workflows. +const ( + createOperatingSystemMethod = "/forge.Forge/CreateOperatingSystem" + updateOperatingSystemMethod = "/forge.Forge/UpdateOperatingSystem" + deleteOperatingSystemMethod = "/forge.Forge/DeleteOperatingSystem" +) + +// syncOperatingSystemToSitesViaProxy pushes an iPXE / Templated iPXE Operating +// System create or update to each associated site through the generic NICo Core +// gRPC proxy, updating each site association's status (Synced on success, Error +// on failure). The same request proto is sent to every site (the OS definition is +// site-independent). It returns the number of sites that failed to sync. +func syncOperatingSystemToSitesViaProxy( + ctx context.Context, + logger zerolog.Logger, + dbSession *cdb.Session, + scp *sc.ClientPool, + ossas []cdbm.OperatingSystemSiteAssociation, + fullMethod string, + req proto.Message, +) int { + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + siteErrors := 0 + for _, ossa := range ossas { + slogger := logger.With().Str("Site ID", ossa.SiteID.String()).Logger() + + stc, cerr := scp.GetClientByID(ossa.SiteID) + if cerr != nil { + slogger.Error().Err(cerr).Msg("failed to retrieve Temporal client for Site") + updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to connect to site") + siteErrors++ + continue + } + + // The site ID is the shared key used to encrypt any redacted secret fields + // for transport; no top-level secret fields are redacted here (artifact + // authTokens are nested and carried as-is). + code, perr := common.ExecuteCoreGRPC(ctx, stc, fullMethod, req, nil, ossa.SiteID.String()) + if perr != nil { + slogger.Error().Err(perr).Int("code", code).Msg("failed to sync Operating System to site via Core proxy") + updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusError, "failed to sync Operating System to site") + siteErrors++ + continue + } + + updateOSSAStatusViaProxy(ctx, slogger, ossaDAO, sdDAO, ossa.ID, cdbm.OperatingSystemSiteAssociationStatusSynced, "Operating System successfully synced to site") + } + return siteErrors +} + +// updateOSSAStatusViaProxy updates an Operating System Site Association status and +// records a status detail entry. Failures are logged but not propagated, mirroring +// the best-effort status bookkeeping of the existing image sync paths. +func updateOSSAStatusViaProxy(ctx context.Context, logger zerolog.Logger, ossaDAO cdbm.OperatingSystemSiteAssociationDAO, sdDAO cdbm.StatusDetailDAO, ossaID uuid.UUID, status string, message string) { + if _, err := ossaDAO.Update(ctx, nil, cdbm.OperatingSystemSiteAssociationUpdateInput{ + OperatingSystemSiteAssociationID: ossaID, + Status: cutil.GetPtr(status), + }); err != nil { + logger.Error().Err(err).Str("Status", status).Msg("failed to update Operating System Site Association status") + return + } + if _, err := sdDAO.CreateFromParams(ctx, nil, ossaID.String(), status, &message); err != nil { + logger.Error().Err(err).Msg("failed to create status detail for Operating System Site Association") + } +} + +// updateOperatingSystemAggregateStatus sets the Operating System's aggregate status +// after a proxy sync attempt: Ready when all sites synced, Error when one or more failed. +func updateOperatingSystemAggregateStatus(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, osID uuid.UUID, hadErrors bool) { + osDAO := cdbm.NewOperatingSystemDAO(dbSession) + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + + status := cdbm.OperatingSystemStatusReady + message := "Operating System successfully synced to all sites" + if hadErrors { + status = cdbm.OperatingSystemStatusError + message = "failed to sync Operating System to one or more sites" + } + + if _, err := osDAO.Update(ctx, nil, cdbm.OperatingSystemUpdateInput{OperatingSystemId: osID, Status: &status}); err != nil { + logger.Error().Err(err).Msg("failed to update aggregate Operating System status") + return + } + if _, err := sdDAO.CreateFromParams(ctx, nil, osID.String(), status, &message); err != nil { + logger.Error().Err(err).Msg("failed to create status detail for aggregate Operating System status") + } +} + +// getRegisteredTenantSites returns all Registered sites the tenant has access to. +// Used to resolve target sites for Global-scope Templated iPXE Operating Systems. +func getRegisteredTenantSites(ctx context.Context, dbSession *cdb.Session, tenantID uuid.UUID) ([]cdbm.Site, error) { + tsDAO := cdbm.NewTenantSiteDAO(dbSession) + tss, _, err := tsDAO.GetAll(ctx, nil, + cdbm.TenantSiteFilterInput{TenantIDs: []uuid.UUID{tenantID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if err != nil { + return nil, err + } + if len(tss) == 0 { + return nil, nil + } + siteIDs := make([]uuid.UUID, len(tss)) + for i, ts := range tss { + siteIDs[i] = ts.SiteID + } + stDAO := cdbm.NewSiteDAO(dbSession) + sites, _, err := stDAO.GetAll(ctx, nil, + cdbm.SiteFilterInput{SiteIDs: siteIDs, Statuses: []string{cdbm.SiteStatusRegistered}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, nil) + if err != nil { + return nil, err + } + return sites, nil +} + // ~~~~~ Create Handler ~~~~~ // // CreateOperatingSystemHandler is the API Handler for creating new OperatingSystem @@ -159,11 +279,9 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { }) } - // check OS type from request - osType := cdbm.OperatingSystemTypeImage - if apiRequest.IpxeScript != nil { - osType = cdbm.OperatingSystemTypeIPXE - } + // Infer OS type from the provided source fields (ipxeScript -> iPXE, + // ipxeTemplateId -> Templated iPXE, otherwise Image). + osType := apiRequest.GetOperatingSystemType() // Set the phoneHomeEnabled if provided in request phoneHomeEnabled := false @@ -232,10 +350,23 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { rdbst = append(rdbst, *site) } - // Create status based on OS type + // Global-scope Templated iPXE Operating Systems are synced to every Registered + // site the tenant has access to (siteIds are not specified for Global scope). + if osType == cdbm.OperatingSystemTypeTemplatedIPXE && apiRequest.Scope != nil && *apiRequest.Scope == cdbm.OperatingSystemScopeGlobal { + globalSites, gerr := getRegisteredTenantSites(ctx, csh.dbSession, tenant.ID) + if gerr != nil { + logger.Error().Err(gerr).Msg("error retrieving tenant sites for global-scope Operating System") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve tenant sites, DB error", nil) + } + rdbst = append(rdbst, globalSites...) + } + + // Create status based on OS type. iPXE / Templated iPXE definitions are pushed + // to sites after commit, so Templated iPXE starts in Syncing; raw iPXE (no site + // associations) is immediately Ready. osStatus := cdbm.OperatingSystemStatusReady osStatusMessage := "Operating System is ready for use" - if osType == cdbm.OperatingSystemTypeImage { + if osType == cdbm.OperatingSystemTypeImage || osType == cdbm.OperatingSystemTypeTemplatedIPXE { osStatus = cdbm.OperatingSystemStatusSyncing osStatusMessage = "received Operating System creation request, syncing" } @@ -251,26 +382,30 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { err = cdb.WithTx(ctx, csh.dbSession, func(tx *cdb.Tx) error { // Create the db record for Operating System osInput := cdbm.OperatingSystemCreateInput{ - Name: apiRequest.Name, - Description: apiRequest.Description, - Org: org, - TenantID: &tenant.ID, - OsType: osType, - ImageURL: apiRequest.ImageURL, - ImageSHA: apiRequest.ImageSHA, - ImageAuthType: apiRequest.ImageAuthType, - ImageAuthToken: apiRequest.ImageAuthToken, - ImageDisk: apiRequest.ImageDisk, - RootFsId: apiRequest.RootFsID, - RootFsLabel: apiRequest.RootFsLabel, - IpxeScript: apiRequest.IpxeScript, - UserData: apiRequest.UserData, - IsCloudInit: apiRequest.IsCloudInit, - AllowOverride: apiRequest.AllowOverride, - EnableBlockStorage: apiRequest.EnableBlockStorage, - PhoneHomeEnabled: phoneHomeEnabled, - Status: osStatus, - CreatedBy: dbUser.ID, + Name: apiRequest.Name, + Description: apiRequest.Description, + Org: org, + TenantID: &tenant.ID, + OsType: osType, + ImageURL: apiRequest.ImageURL, + ImageSHA: apiRequest.ImageSHA, + ImageAuthType: apiRequest.ImageAuthType, + ImageAuthToken: apiRequest.ImageAuthToken, + ImageDisk: apiRequest.ImageDisk, + RootFsId: apiRequest.RootFsID, + RootFsLabel: apiRequest.RootFsLabel, + IpxeScript: apiRequest.IpxeScript, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + IpxeOsScope: apiRequest.Scope, + 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 { @@ -347,8 +482,13 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { } dbossa = retossa - // Trigger workflows to sync Image based Operating System with various Sites + // Trigger workflows to sync Image based Operating System with various Sites. + // iPXE / Templated iPXE definitions are pushed to sites via the Core gRPC + // proxy after the transaction commits (see below), so they are skipped here. for _, ossa := range dbossa { + if os.Type != cdbm.OperatingSystemTypeImage { + continue + } // Iteration body wrapped in a function literal so `defer cancel()` // scopes to the iteration; otherwise the deferred cancels would // pile up until the WithTx closure returns. @@ -424,12 +564,57 @@ func (csh CreateOperatingSystemHandler) Handle(c echo.Context) error { return timeoutResp() } + // Push iPXE / Templated iPXE Operating Systems to associated sites through the + // generic Core gRPC proxy (Image OSes are synced in-transaction above). Per-site + // failures are recorded on the association status and do not fail the request. + if cdbm.IsIPXEType(os.Type) && len(dbossa) > 0 { + req := model.BuildCreateOperatingSystemRequest(os) + siteErrors := syncOperatingSystemToSitesViaProxy(ctx, logger, csh.dbSession, csh.scp, dbossa, createOperatingSystemMethod, req) + updateOperatingSystemAggregateStatus(ctx, logger, csh.dbSession, os.ID, siteErrors > 0) + os, dbossd, dbossa = reloadOperatingSystemForResponse(ctx, logger, csh.dbSession, os) + } + // create response apiOperatingSystem := model.NewAPIOperatingSystem(os, dbossd, dbossa, sttsmap) logger.Info().Msg("finishing API handler") return c.JSON(http.StatusCreated, apiOperatingSystem) } +// reloadOperatingSystemForResponse re-reads the Operating System, its recent status +// details, and its site associations after a proxy sync so the API response reflects +// the post-sync state. Best-effort: on any read error the prior values are kept. +func reloadOperatingSystemForResponse(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, os *cdbm.OperatingSystem) (*cdbm.OperatingSystem, []cdbm.StatusDetail, []cdbm.OperatingSystemSiteAssociation) { + osDAO := cdbm.NewOperatingSystemDAO(dbSession) + ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dbSession) + sdDAO := cdbm.NewStatusDetailDAO(dbSession) + + reloadedOS := os + if v, err := osDAO.GetByID(ctx, nil, os.ID, nil); err == nil { + reloadedOS = v + } else { + logger.Warn().Err(err).Msg("failed to reload Operating System for response") + } + + var ssds []cdbm.StatusDetail + if v, err := sdDAO.GetRecentByEntityIDs(ctx, nil, []string{os.ID.String()}, common.RECENT_STATUS_DETAIL_COUNT); err == nil { + ssds = v + } else { + logger.Warn().Err(err).Msg("failed to reload Operating System status details for response") + } + + var ossas []cdbm.OperatingSystemSiteAssociation + if v, _, err := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{os.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + []string{cdbm.SiteRelationName}); err == nil { + ossas = v + } else { + logger.Warn().Err(err).Msg("failed to reload Operating System site associations for response") + } + + return reloadedOS, ssds, ossas +} + // ~~~~~ GetAll Handler ~~~~~ // // GetAllOperatingSystemHandler is the API Handler for getting all OperatingSystems @@ -1103,7 +1288,7 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { if apiRequest.IsActive != nil && *apiRequest.IsActive { osStatusMessage = "Operating System has been reactivated and is ready for use" } - if os.Type == cdbm.OperatingSystemTypeImage { + if os.Type == cdbm.OperatingSystemTypeImage || os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { osStatus = cutil.GetPtr(cdbm.OperatingSystemStatusSyncing) osStatusMessage = "received Operating System update request, syncing" } @@ -1129,24 +1314,27 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { } } updatedOs, derr := osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{ - OperatingSystemId: osID, - Name: apiRequest.Name, - Description: apiRequest.Description, - ImageURL: apiRequest.ImageURL, - ImageSHA: apiRequest.ImageSHA, - ImageAuthType: apiRequest.ImageAuthType, - ImageAuthToken: apiRequest.ImageAuthToken, - ImageDisk: apiRequest.ImageDisk, - RootFsId: apiRequest.RootFsID, - RootFsLabel: apiRequest.RootFsLabel, - IpxeScript: apiRequest.IpxeScript, - UserData: apiRequest.UserData, - IsCloudInit: apiRequest.IsCloudInit, - AllowOverride: apiRequest.AllowOverride, - PhoneHomeEnabled: apiRequest.PhoneHomeEnabled, - IsActive: apiRequest.IsActive, - DeactivationNote: deactivationNote, - Status: osStatus, + OperatingSystemId: osID, + Name: apiRequest.Name, + Description: apiRequest.Description, + ImageURL: apiRequest.ImageURL, + ImageSHA: apiRequest.ImageSHA, + ImageAuthType: apiRequest.ImageAuthType, + ImageAuthToken: apiRequest.ImageAuthToken, + ImageDisk: apiRequest.ImageDisk, + RootFsId: apiRequest.RootFsID, + RootFsLabel: apiRequest.RootFsLabel, + IpxeScript: apiRequest.IpxeScript, + IpxeTemplateId: apiRequest.IpxeTemplateId, + IpxeTemplateParameters: apiRequest.IpxeTemplateParameters, + IpxeTemplateArtifacts: apiRequest.IpxeTemplateArtifacts, + UserData: apiRequest.UserData, + 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") @@ -1298,6 +1486,26 @@ func (ush UpdateOperatingSystemHandler) Handle(c echo.Context) error { return timeoutResp() } + // Push iPXE / Templated iPXE Operating System updates to associated sites via the + // generic Core gRPC proxy (Image OSes are synced in-transaction above). Raw iPXE + // OSes without site associations have nothing to push. + if cdbm.IsIPXEType(uos.Type) { + ipxeOssas, _, oerr := ossaDAO.GetAll(ctx, nil, + cdbm.OperatingSystemSiteAssociationFilterInput{OperatingSystemIDs: []uuid.UUID{uos.ID}}, + cdbp.PageInput{Limit: cutil.GetPtr(cdbp.TotalLimit)}, + []string{cdbm.SiteRelationName}) + if oerr != nil { + logger.Error().Err(oerr).Msg("error retrieving Operating System Site associations for proxy sync") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) + } + if len(ipxeOssas) > 0 { + req := model.BuildUpdateOperatingSystemRequest(uos) + siteErrors := syncOperatingSystemToSitesViaProxy(ctx, logger, ush.dbSession, ush.scp, ipxeOssas, updateOperatingSystemMethod, req) + updateOperatingSystemAggregateStatus(ctx, logger, ush.dbSession, uos.ID, siteErrors > 0) + uos, ssds, dbossas = reloadOperatingSystemForResponse(ctx, logger, ush.dbSession, uos) + } + } + // Send response apiOperatingSystem := model.NewAPIOperatingSystem(uos, ssds, dbossas, sttsmap) logger.Info().Msg("finishing API handler") @@ -1408,7 +1616,10 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { // Verify if Site is in Registered state ossaDAO := cdbm.NewOperatingSystemSiteAssociationDAO(dsh.dbSession) ossasToDelete := []cdbm.OperatingSystemSiteAssociation{} - if os.Type == cdbm.OperatingSystemTypeImage { + // Image and Templated iPXE Operating Systems propagate deletes to their + // associated sites (Image via OsImage workflows, Templated iPXE via the Core + // gRPC proxy), so their associations must be loaded. + if os.Type == cdbm.OperatingSystemTypeImage || os.Type == cdbm.OperatingSystemTypeTemplatedIPXE { ossasToDelete, _, err = ossaDAO.GetAll( ctx, nil, @@ -1423,11 +1634,13 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Operating System Site associations from DB", nil) } - // Verify if associated Site is not registered state - for _, dbosa := range ossasToDelete { - if dbosa.Site.Status != cdbm.SiteStatusRegistered { - logger.Warn().Msg(fmt.Sprintf("unable to delete Operating System. Site: %s. is not in Registered state", dbosa.SiteID.String())) - return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to delete Operating System, Associated Site: %s is not in Registered state", dbosa.Site.Name), nil) + // Verify if associated Site is not registered state (image-based only). + if os.Type == cdbm.OperatingSystemTypeImage { + for _, dbosa := range ossasToDelete { + if dbosa.Site.Status != cdbm.SiteStatusRegistered { + logger.Warn().Msg(fmt.Sprintf("unable to delete Operating System. Site: %s. is not in Registered state", dbosa.SiteID.String())) + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Failed to delete Operating System, Associated Site: %s is not in Registered state", dbosa.Site.Name), nil) + } } } } @@ -1577,6 +1790,22 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { } } + // Templated iPXE Operating Systems mark the OS (and associations) as + // Deleting in-transaction; the deletes are pushed to sites via the Core + // gRPC proxy after commit, and the OS is soft-deleted once all sites are + // cleaned up. + if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE && len(ossasToDelete) > 0 { + if _, derr := osDAO.Update(ctx, tx, cdbm.OperatingSystemUpdateInput{OperatingSystemId: os.ID, Status: cutil.GetPtr(cdbm.OperatingSystemStatusDeleting)}); derr != nil { + logger.Error().Err(derr).Msg("error updating Operating System in DB") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to delete Operating System", nil) + } + sdDAO := cdbm.NewStatusDetailDAO(dsh.dbSession) + if _, derr := sdDAO.CreateFromParams(ctx, tx, os.ID.String(), cdbm.OperatingSystemStatusDeleting, cutil.GetPtr("received request for deletion, pending processing")); derr != nil { + logger.Error().Err(derr).Msg("error creating Status Detail DB entry") + return cutil.NewAPIError(http.StatusInternalServerError, "Failed to create Status Detail for Operating System", nil) + } + } + // Delete OS if its not Image // Delete OS if there is no Operating Site Association in case of Image based OS if os.Type == cdbm.OperatingSystemTypeIPXE || len(ossasToDelete) == 0 { @@ -1603,6 +1832,44 @@ func (dsh DeleteOperatingSystemHandler) Handle(c echo.Context) error { return timeoutResp() } + // Push deletes for Templated iPXE Operating Systems to associated sites via the + // generic Core gRPC proxy, remove the synced associations, and soft-delete the + // OS once every site is cleaned up. A not-found object on a site is treated as + // already deleted. + if os.Type == cdbm.OperatingSystemTypeTemplatedIPXE && len(ossasToDelete) > 0 { + req := model.BuildDeleteOperatingSystemRequest(os) + remaining := 0 + for _, ossa := range ossasToDelete { + slogger := logger.With().Str("Site ID", ossa.SiteID.String()).Logger() + stc, cerr := dsh.scp.GetClientByID(ossa.SiteID) + if cerr != nil { + slogger.Error().Err(cerr).Msg("failed to retrieve Temporal client for Site") + remaining++ + continue + } + code, perr := common.ExecuteCoreGRPC(ctx, stc, deleteOperatingSystemMethod, req, nil, ossa.SiteID.String()) + if perr != nil { + if code == http.StatusNotFound { + slogger.Warn().Msg("Operating System not found on site, treating delete as successful") + } else { + slogger.Error().Err(perr).Int("code", code).Msg("failed to delete Operating System on site via Core proxy") + remaining++ + continue + } + } + if derr := ossaDAO.Delete(ctx, nil, ossa.ID); derr != nil { + slogger.Error().Err(derr).Msg("failed to delete Operating System Site Association after site delete") + remaining++ + } + } + if remaining == 0 { + if derr := osDAO.Delete(ctx, nil, os.ID); derr != nil { + logger.Error().Err(derr).Msg("failed to soft-delete Operating System after all sites cleaned up") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to delete Operating System", nil) + } + } + } + // Create response logger.Info().Msg("finishing API handler") return c.JSON(http.StatusAccepted, model.NewAPIDeletionAcceptedResponse()) diff --git a/rest-api/api/pkg/api/model/ipxetemplate.go b/rest-api/api/pkg/api/model/ipxetemplate.go new file mode 100644 index 0000000000..e8c6c60815 --- /dev/null +++ b/rest-api/api/pkg/api/model/ipxetemplate.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "time" + + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" +) + +// APIIpxeTemplate is the data structure to capture the API representation of an iPXE template. +// +// iPXE templates are global in REST and identified by the stable UUID assigned by core +// (`ID`). Per-site availability is tracked separately and not surfaced in this payload. +type APIIpxeTemplate struct { + // ID is the stable template UUID assigned by core, identical between core and REST + ID string `json:"id"` + // Name is the globally unique template name (e.g. "ubuntu-autoinstall", "kernel-initrd") + Name string `json:"name"` + // Template is the raw iPXE script content + Template string `json:"template"` + // RequiredParams lists the parameters that must be provided to render the template + RequiredParams []string `json:"requiredParams"` + // ReservedParams lists the parameters that are reserved by the template and cannot be user-supplied + ReservedParams []string `json:"reservedParams"` + // RequiredArtifacts lists the artifact names (e.g. "kernel", "initrd") required for the template + RequiredArtifacts []string `json:"requiredArtifacts"` + // 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..b58cf6f9f0 100644 --- a/rest-api/api/pkg/api/model/operatingsystem.go +++ b/rest-api/api/pkg/api/model/operatingsystem.go @@ -5,6 +5,8 @@ package model import ( "errors" + "fmt" + "strings" "time" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -65,6 +67,29 @@ type APIOperatingSystemCreateRequest struct { AllowOverride bool `json:"allowOverride"` // EnableBlockStorage indicates whether the Operating System image will be stored remotely via block storage EnableBlockStorage bool `json:"enableBlockStorage"` + // IpxeTemplateId is the ID of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and nico-core. + // Allowed values: "Global" (rest->core, all owner sites), "Limited" (rest->core, specific + // sites listed in siteIds). Required for Templated iPXE OS. For raw iPXE OS, only "Global" + // or unspecified is accepted. Rejected for Image OS. + Scope *string `json:"scope"` +} + +// GetOperatingSystemType returns the OperatingSystem type inferred from the +// create request's source fields (`IpxeScript`, `IpxeTemplateId`, or neither). +func (oscr *APIOperatingSystemCreateRequest) GetOperatingSystemType() string { + if oscr.IpxeScript != nil { + return cdbm.OperatingSystemTypeIPXE + } + if oscr.IpxeTemplateId != nil { + return cdbm.OperatingSystemTypeTemplatedIPXE + } + return cdbm.OperatingSystemTypeImage } // Validate ensure the values passed in request are acceptable @@ -83,6 +108,18 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { return err } + if oscr.IpxeTemplateId != nil && strings.TrimSpace(*oscr.IpxeTemplateId) == "" { + return validation.Errors{ + "ipxeTemplateId": errors.New("must not be empty"), + } + } + + if oscr.IpxeScript != nil && oscr.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("ipxeScript and ipxeTemplateId are mutually exclusive"), + } + } + // Make sure siteIds only required in case of image is OS based if oscr.IpxeScript != nil && len(oscr.SiteIDs) > 0 { return validation.Errors{ @@ -90,13 +127,13 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { } } - if oscr.IpxeScript != nil && oscr.ImageURL != nil { + if (oscr.IpxeScript != nil || oscr.IpxeTemplateId != nil) && oscr.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), } - } else if oscr.IpxeScript == nil && oscr.ImageURL == nil { + } else if oscr.IpxeScript == nil && oscr.IpxeTemplateId == nil && oscr.ImageURL == nil { return validation.Errors{ - validationCommonErrorField: errors.New("either imageURL or ipxeScript must be specified"), + validationCommonErrorField: errors.New("one of imageURL, ipxeScript, or ipxeTemplateId must be specified"), } } @@ -106,6 +143,42 @@ func (oscr *APIOperatingSystemCreateRequest) Validate() error { } } + // iPXE template definition fields are only valid for Templated iPXE Operating Systems. + if oscr.IpxeTemplateId == nil { + if len(oscr.IpxeTemplateParameters) > 0 { + return validation.Errors{ + "ipxeTemplateParameters": errors.New("can only be specified for Templated iPXE Operating Systems"), + } + } + if len(oscr.IpxeTemplateArtifacts) > 0 { + return validation.Errors{ + "ipxeTemplateArtifacts": errors.New("can only be specified for Templated iPXE Operating Systems"), + } + } + } + + // Scope handling differs by OS type. Templated iPXE is validated in full by + // validateTemplatedIpxeOS (including its own image-field/site-id rules), so it + // returns early and never falls through to the image/raw-iPXE checks below. + switch { + case oscr.IpxeTemplateId != nil: + return oscr.validateTemplatedIpxeOS() + case oscr.IpxeScript != nil: + // raw iPXE: scope is optional but must be Global when set. + if oscr.Scope != nil && *oscr.Scope != cdbm.OperatingSystemScopeGlobal { + return validation.Errors{ + "scope": fmt.Errorf("scope must be %q or unspecified for raw iPXE Operating Systems", cdbm.OperatingSystemScopeGlobal), + } + } + default: + // Image: scope is not applicable. + if oscr.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope can only be specified for Templated iPXE Operating Systems"), + } + } + } + if oscr.ImageURL != nil { err = validation.ValidateStruct(oscr, validation.Field(&oscr.ImageURL, is.URL), @@ -279,6 +352,14 @@ type APIOperatingSystemUpdateRequest struct { IsActive *bool `json:"isActive"` // DeactivationNote is the deactivation note if any DeactivationNote *string `json:"deactivationNote"` + // IpxeTemplateId is the ID of the iPXE template to use (alternative to a raw ipxeScript) + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters to pass to the iPXE template + IpxeTemplateParameters *[]cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition + IpxeTemplateArtifacts *[]cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope is immutable after creation. If provided, the request is rejected. + Scope *string `json:"scope"` } // Validate ensure the values passed in request are acceptable @@ -314,6 +395,69 @@ func (osur *APIOperatingSystemUpdateRequest) Validate(existingOS *cdbm.Operating } } + // Scope is immutable after creation. + if osur.Scope != nil { + return validation.Errors{ + "scope": errors.New("scope cannot be changed after creation"), + } + } + + // iPXE script and template are mutually exclusive in a single request. + if osur.IpxeScript != nil && osur.IpxeTemplateId != nil { + return validation.Errors{ + "ipxeTemplateId": errors.New("ipxeScript and ipxeTemplateId are mutually exclusive"), + } + } + if osur.IpxeTemplateId != nil && strings.TrimSpace(*osur.IpxeTemplateId) == "" { + return validation.Errors{ + "ipxeTemplateId": errors.New("must not be empty"), + } + } + if osur.IpxeTemplateId != nil && osur.ImageURL != nil { + return validation.Errors{ + "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), + } + } + + // Reject cross-type field assignments based on the existing OS type and + // validate iPXE template definition fields (Templated iPXE only). + switch existingOS.Type { + case cdbm.OperatingSystemTypeImage: + if osur.IpxeTemplateId != nil { + return validation.Errors{"ipxeTemplateId": errors.New("unable to set iPXE template for image based Operating System")} + } + case cdbm.OperatingSystemTypeIPXE: + if osur.IpxeTemplateId != nil { + return validation.Errors{"ipxeTemplateId": errors.New("unable to set iPXE template for raw iPXE Operating System")} + } + case cdbm.OperatingSystemTypeTemplatedIPXE: + if osur.IpxeScript != nil { + return validation.Errors{"ipxeScript": errors.New("unable to set iPXE script for templated iPXE Operating System")} + } + if osur.ImageURL != nil { + return validation.Errors{"imageURL": errors.New("unable to set image URL for iPXE based Operating System")} + } + } + if existingOS.Type == cdbm.OperatingSystemTypeTemplatedIPXE { + if osur.IpxeTemplateParameters != nil { + if verr := validateIpxeTemplateParameters(*osur.IpxeTemplateParameters); verr != nil { + return verr + } + } + if osur.IpxeTemplateArtifacts != nil { + if verr := validateIpxeTemplateArtifacts(*osur.IpxeTemplateArtifacts); verr != nil { + return verr + } + } + } else { + if osur.IpxeTemplateParameters != nil { + return validation.Errors{"ipxeTemplateParameters": errors.New("can only be specified for Templated iPXE Operating Systems")} + } + if osur.IpxeTemplateArtifacts != nil { + return validation.Errors{"ipxeTemplateArtifacts": errors.New("can only be specified for Templated iPXE Operating Systems")} + } + } + if osur.IpxeScript != nil && osur.ImageURL != nil { return validation.Errors{ "imageURL": errors.New("cannot be specified for iPXE based Operating Systems"), @@ -563,6 +707,16 @@ type APIOperatingSystem struct { RootFsLabel *string `json:"rootFsLabel"` // IpxeScript is the ipxe ocript for the Operating System IpxeScript *string `json:"ipxeScript"` + // IpxeTemplateId is the ID of the iPXE template used by this Operating System + IpxeTemplateId *string `json:"ipxeTemplateId"` + // IpxeTemplateParameters are the parameters passed to the iPXE template + IpxeTemplateParameters []cdbm.OperatingSystemIpxeParameter `json:"ipxeTemplateParameters"` + // IpxeTemplateArtifacts are the artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition. + // Any artifact authToken is redacted in API responses. + IpxeTemplateArtifacts []cdbm.OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts"` + // Scope controls the synchronization direction between carbide-rest and nico-core. + // One of "Local", "Global", or "Limited". Only set for iPXE-based Operating Systems. + Scope *string `json:"scope"` // PhoneHomeEnabled is an attribute which is specified by user if Operating System needs to be enabled for phone home or not PhoneHomeEnabled bool `json:"phoneHomeEnabled"` // UserData is the user data for the Operating System @@ -604,6 +758,8 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail RootFsID: dbOS.RootFsID, RootFsLabel: dbOS.RootFsLabel, IpxeScript: dbOS.IpxeScript, + IpxeTemplateId: dbOS.IpxeTemplateId, + Scope: dbOS.IpxeOsScope, PhoneHomeEnabled: dbOS.PhoneHomeEnabled, UserData: dbOS.UserData, IsCloudInit: dbOS.IsCloudInit, @@ -615,6 +771,16 @@ func NewAPIOperatingSystem(dbOS *cdbm.OperatingSystem, dbsds []cdbm.StatusDetail Created: dbOS.Created, Updated: dbOS.Updated, } + apiOperatingSystem.IpxeTemplateParameters = dbOS.IpxeTemplateParameters + // Redact artifact auth tokens: never echo stored secrets back to clients. + if dbOS.IpxeTemplateArtifacts != nil { + redactedArtifacts := make([]cdbm.OperatingSystemIpxeArtifact, len(dbOS.IpxeTemplateArtifacts)) + for i, artifact := range dbOS.IpxeTemplateArtifacts { + artifact.AuthToken = nil + redactedArtifacts[i] = artifact + } + apiOperatingSystem.IpxeTemplateArtifacts = redactedArtifacts + } if dbOS.InfrastructureProviderID != nil { apiOperatingSystem.InfrastructureProviderID = cutil.GetPtr(dbOS.InfrastructureProviderID.String()) } @@ -663,3 +829,168 @@ func NewAPIOperatingSystemSummary(dbos *cdbm.OperatingSystem) *APIOperatingSyste return &aos } + +// validateTemplatedIpxeOS fully validates a Templated iPXE create request: image +// fields must be absent, scope must be Global or Limited (Local is rejected at +// creation), siteIds are required only for Limited scope, and the template +// parameters/artifacts must be well-formed. +func (oscr *APIOperatingSystemCreateRequest) validateTemplatedIpxeOS() error { + if err := validation.ValidateStruct(oscr, + validation.Field(&oscr.ImageSHA, validation.Nil.Error("imageSHA cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.ImageAuthType, validation.Nil.Error("imageAuthType cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.ImageAuthToken, validation.Nil.Error("imageAuthToken cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.ImageDisk, validation.Nil.Error("imageDisk cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.RootFsID, validation.Nil.Error("rootFsId cannot be specified for Templated iPXE Operating Systems")), + validation.Field(&oscr.RootFsLabel, validation.Nil.Error("rootFsLabel cannot be specified for Templated iPXE Operating Systems")), + ); err != nil { + return err + } + + if oscr.Scope == nil { + return validation.Errors{"scope": errors.New("scope is required for Templated iPXE Operating Systems")} + } + switch *oscr.Scope { + case cdbm.OperatingSystemScopeGlobal, cdbm.OperatingSystemScopeLimited: + // valid + case cdbm.OperatingSystemScopeLocal: + return validation.Errors{"scope": errors.New("scope 'Local' cannot be specified at creation; Local Operating Systems are created in nico-core")} + default: + return validation.Errors{"scope": errors.New("scope must be one of 'Global' or 'Limited'")} + } + + if len(oscr.SiteIDs) > 0 && *oscr.Scope != cdbm.OperatingSystemScopeLimited { + return validation.Errors{"siteIds": errors.New("siteIds can only be specified for Templated iPXE Operating Systems with scope 'Limited'")} + } + if *oscr.Scope == cdbm.OperatingSystemScopeLimited && len(oscr.SiteIDs) == 0 { + return validation.Errors{"siteIds": errors.New("at least one siteId must be specified when scope is 'Limited'")} + } + + if err := validateIpxeTemplateParameters(oscr.IpxeTemplateParameters); err != nil { + return err + } + if err := validateIpxeTemplateArtifacts(oscr.IpxeTemplateArtifacts); err != nil { + return err + } + return nil +} + +// validCacheStrategies is the set of accepted artifact CacheStrategy string values. +// It is derived from the DB model's strategy map so the API and persistence layers +// agree on the canonical (friendly) strategy names. +var validCacheStrategies = func() map[string]struct{} { + m := make(map[string]struct{}, len(cdbm.OperatingSystemIpxeArtifactCacheStrategyToProtoMap)) + for name := range cdbm.OperatingSystemIpxeArtifactCacheStrategyToProtoMap { + m[name] = struct{}{} + } + return m +}() + +func validateIpxeTemplateParameters(params []cdbm.OperatingSystemIpxeParameter) error { + for i, p := range params { + if strings.TrimSpace(p.Name) == "" { + return validation.Errors{"ipxeTemplateParameters": fmt.Errorf("entry %d: name is required", i)} + } + } + return nil +} + +func validateIpxeTemplateArtifacts(artifacts []cdbm.OperatingSystemIpxeArtifact) error { + for i, a := range artifacts { + if strings.TrimSpace(a.Name) == "" { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d: name is required", i)} + } + if strings.TrimSpace(a.URL) == "" { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): url is required", i, a.Name)} + } + if _, ok := validCacheStrategies[a.CacheStrategy]; !ok { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): cacheStrategy must be one of CacheAsNeeded, LocalOnly, CachedOnly, RemoteOnly", i, a.Name)} + } + if a.AuthType != nil && *a.AuthType != "" { + at := *a.AuthType + if at != cdbm.OperatingSystemAuthTypeBasic && at != cdbm.OperatingSystemAuthTypeBearer { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authType must be Basic or Bearer", i, a.Name)} + } + if a.AuthToken == nil || *a.AuthToken == "" { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authToken is required when authType is specified", i, a.Name)} + } + } + if a.AuthToken != nil && *a.AuthToken != "" && (a.AuthType == nil || *a.AuthType == "") { + return validation.Errors{"ipxeTemplateArtifacts": fmt.Errorf("entry %d (%s): authType must be specified when authToken is provided", i, a.Name)} + } + } + return nil +} + +// BuildCreateOperatingSystemRequest builds the forge.Forge CreateOperatingSystem +// request proto from a persisted Operating System record. It is used by the OS +// handler to push iPXE / Templated iPXE definitions to on-site NICo Core through +// the generic Core gRPC proxy. +// +// Note: artifact authTokens are nested inside the repeated artifacts message and +// are therefore carried as-is (the proxy cannot redact nested fields). +func BuildCreateOperatingSystemRequest(os *cdbm.OperatingSystem) *cwssaws.CreateOperatingSystemRequest { + return &cwssaws.CreateOperatingSystemRequest{ + Id: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + Name: os.Name, + Description: os.Description, + TenantOrganizationId: os.Org, + IsActive: os.IsActive, + AllowOverride: os.AllowOverride, + PhoneHomeEnabled: os.PhoneHomeEnabled, + UserData: os.UserData, + IpxeScript: os.IpxeScript, + IpxeTemplateId: ipxeTemplateIDProto(os.IpxeTemplateId), + IpxeTemplateParameters: ipxeParametersProto(os.IpxeTemplateParameters), + IpxeTemplateArtifacts: ipxeArtifactsProto(os.IpxeTemplateArtifacts), + } +} + +// BuildUpdateOperatingSystemRequest builds the forge.Forge UpdateOperatingSystem +// request proto from a persisted Operating System record. +func BuildUpdateOperatingSystemRequest(os *cdbm.OperatingSystem) *cwssaws.UpdateOperatingSystemRequest { + return &cwssaws.UpdateOperatingSystemRequest{ + Id: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + Name: &os.Name, + Description: os.Description, + IsActive: &os.IsActive, + AllowOverride: &os.AllowOverride, + PhoneHomeEnabled: &os.PhoneHomeEnabled, + UserData: os.UserData, + IpxeScript: os.IpxeScript, + IpxeTemplateId: ipxeTemplateIDProto(os.IpxeTemplateId), + IpxeTemplateParameters: &cwssaws.IpxeTemplateParameters{Items: ipxeParametersProto(os.IpxeTemplateParameters)}, + IpxeTemplateArtifacts: &cwssaws.IpxeTemplateArtifacts{Items: ipxeArtifactsProto(os.IpxeTemplateArtifacts)}, + IpxeTemplateDefinitionHash: os.IpxeTemplateDefinitionHash, + } +} + +// BuildDeleteOperatingSystemRequest builds the forge.Forge DeleteOperatingSystem +// request proto for a persisted Operating System record. +func BuildDeleteOperatingSystemRequest(os *cdbm.OperatingSystem) *cwssaws.DeleteOperatingSystemRequest { + return &cwssaws.DeleteOperatingSystemRequest{ + Id: &cwssaws.OperatingSystemId{Value: os.ID.String()}, + } +} + +func ipxeTemplateIDProto(id *string) *cwssaws.IpxeTemplateId { + if id == nil { + return nil + } + return &cwssaws.IpxeTemplateId{Value: *id} +} + +func ipxeParametersProto(params []cdbm.OperatingSystemIpxeParameter) []*cwssaws.IpxeTemplateParameter { + out := make([]*cwssaws.IpxeTemplateParameter, 0, len(params)) + for i := range params { + out = append(out, params[i].ToProto()) + } + return out +} + +func ipxeArtifactsProto(artifacts []cdbm.OperatingSystemIpxeArtifact) []*cwssaws.IpxeTemplateArtifact { + out := make([]*cwssaws.IpxeTemplateArtifact, 0, len(artifacts)) + for i := range artifacts { + out = append(out, artifacts[i].ToProto()) + } + return out +} diff --git a/rest-api/api/pkg/api/model/operatingsystem_templated_test.go b/rest-api/api/pkg/api/model/operatingsystem_templated_test.go new file mode 100644 index 0000000000..1a80dec51e --- /dev/null +++ b/rest-api/api/pkg/api/model/operatingsystem_templated_test.go @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" +) + +func TestOperatingSystemCreateRequest_GetOperatingSystemType(t *testing.T) { + assert.Equal(t, cdbm.OperatingSystemTypeIPXE, (&APIOperatingSystemCreateRequest{IpxeScript: cutil.GetPtr("x")}).GetOperatingSystemType()) + assert.Equal(t, cdbm.OperatingSystemTypeTemplatedIPXE, (&APIOperatingSystemCreateRequest{IpxeTemplateId: cutil.GetPtr("t")}).GetOperatingSystemType()) + assert.Equal(t, cdbm.OperatingSystemTypeImage, (&APIOperatingSystemCreateRequest{ImageURL: cutil.GetPtr("http://x")}).GetOperatingSystemType()) +} + +func TestOperatingSystemCreateRequest_Validate_TemplatedAndScope(t *testing.T) { + tmplID := cutil.GetPtr("tmpl-1") + tests := []struct { + desc string + obj APIOperatingSystemCreateRequest + expectErr bool + }{ + { + desc: "templated requires scope", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID}, + expectErr: true, + }, + { + desc: "templated rejects Local scope at create", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLocal)}, + expectErr: true, + }, + { + desc: "templated global is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal)}, + expectErr: false, + }, + { + desc: "templated global rejects siteIds", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), SiteIDs: []string{uuid.NewString()}}, + expectErr: true, + }, + { + desc: "templated limited requires siteIds", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited)}, + expectErr: true, + }, + { + desc: "templated limited with siteIds is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited), SiteIDs: []string{uuid.NewString()}}, + expectErr: false, + }, + { + desc: "templated artifact with valid cache strategy is ok", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://x/k", CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}}}, + expectErr: false, + }, + { + desc: "templated artifact with invalid cache strategy is rejected", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", URL: "http://x/k", CacheStrategy: "BOGUS"}}}, + expectErr: true, + }, + { + desc: "templated artifact missing url is rejected", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeTemplateId: tmplID, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{{Name: "kernel", CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}}}, + expectErr: true, + }, + { + desc: "raw ipxe rejects template parameters", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{{Name: "p", Value: "v"}}}, + expectErr: true, + }, + { + desc: "raw ipxe rejects non-global scope", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), Scope: cutil.GetPtr(cdbm.OperatingSystemScopeLimited)}, + expectErr: true, + }, + { + desc: "ipxeScript and ipxeTemplateId mutually exclusive", + obj: APIOperatingSystemCreateRequest{Name: "abc", IpxeScript: cutil.GetPtr("ipxe"), IpxeTemplateId: tmplID}, + expectErr: true, + }, + { + desc: "image rejects scope", + obj: APIOperatingSystemCreateRequest{Name: "abc", ImageURL: cutil.GetPtr("http://iso.net/iso"), ImageSHA: cutil.GetPtr("a1efca12ea51069abb123bf9c77889fcc2a31cc5483fc14d115e44fdf07c7980"), RootFsID: cutil.GetPtr("666c2eee-193d-42db-a490-4c444342bd4e"), SiteIDs: []string{uuid.NewString()}, Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal)}, + expectErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + err := tc.obj.Validate() + assert.Equal(t, tc.expectErr, err != nil) + }) + } +} + +func TestOperatingSystemUpdateRequest_Validate_ScopeImmutableAndTemplate(t *testing.T) { + templatedOS := &cdbm.OperatingSystem{ID: uuid.New(), Name: "ab", Type: cdbm.OperatingSystemTypeTemplatedIPXE, Status: cdbm.OperatingSystemStatusReady} + rawIpxeOS := &cdbm.OperatingSystem{ID: uuid.New(), Name: "ab", Type: cdbm.OperatingSystemTypeIPXE, IpxeScript: cutil.GetPtr("x"), Status: cdbm.OperatingSystemStatusReady} + + t.Run("scope is immutable", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{Scope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal)}).Validate(templatedOS) + assert.Error(t, err) + }) + t.Run("templated accepts template params", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "p", Value: "v"}}}).Validate(templatedOS) + assert.NoError(t, err) + }) + t.Run("raw ipxe rejects template params", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateParameters: &[]cdbm.OperatingSystemIpxeParameter{{Name: "p", Value: "v"}}}).Validate(rawIpxeOS) + assert.Error(t, err) + }) + t.Run("raw ipxe rejects template id", func(t *testing.T) { + err := (&APIOperatingSystemUpdateRequest{IpxeTemplateId: cutil.GetPtr("t")}).Validate(rawIpxeOS) + assert.Error(t, err) + }) +} + +func TestBuildOperatingSystemRequests(t *testing.T) { + id := uuid.New() + authToken := "secret-token" + os := &cdbm.OperatingSystem{ + ID: id, + Name: "templated-os", + Description: cutil.GetPtr("desc"), + Org: "org-1", + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + IsActive: true, + AllowOverride: true, + PhoneHomeEnabled: true, + UserData: cutil.GetPtr("ud"), + IpxeTemplateId: cutil.GetPtr("tmpl-1"), + IpxeTemplateParameters: []cdbm.OperatingSystemIpxeParameter{ + {Name: "version", Value: "22.04"}, + }, + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://x/k", AuthType: cutil.GetPtr(cdbm.OperatingSystemAuthTypeBearer), AuthToken: &authToken, CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}, + }, + IpxeTemplateDefinitionHash: cutil.GetPtr("hash-1"), + } + + t.Run("create request maps all fields", func(t *testing.T) { + req := BuildCreateOperatingSystemRequest(os) + require.NotNil(t, req) + assert.Equal(t, id.String(), req.GetId().GetValue()) + assert.Equal(t, "templated-os", req.Name) + assert.Equal(t, "org-1", req.TenantOrganizationId) + assert.True(t, req.IsActive) + assert.True(t, req.AllowOverride) + assert.True(t, req.PhoneHomeEnabled) + assert.Equal(t, "tmpl-1", req.GetIpxeTemplateId().GetValue()) + require.Len(t, req.IpxeTemplateParameters, 1) + assert.Equal(t, "version", req.IpxeTemplateParameters[0].Name) + require.Len(t, req.IpxeTemplateArtifacts, 1) + assert.Equal(t, "kernel", req.IpxeTemplateArtifacts[0].Name) + // Cache strategy maps from the friendly name to the proto enum. + assert.Equal(t, cwssaws.IpxeTemplateArtifactCacheStrategy_CACHE_AS_NEEDED, req.IpxeTemplateArtifacts[0].CacheStrategy) + // CachedUrl is never emitted from the rest side. + assert.Nil(t, req.IpxeTemplateArtifacts[0].CachedUrl) + }) + + t.Run("update request maps all fields", func(t *testing.T) { + req := BuildUpdateOperatingSystemRequest(os) + require.NotNil(t, req) + assert.Equal(t, id.String(), req.GetId().GetValue()) + require.NotNil(t, req.Name) + assert.Equal(t, "templated-os", *req.Name) + assert.Equal(t, "tmpl-1", req.GetIpxeTemplateId().GetValue()) + require.NotNil(t, req.IpxeTemplateParameters) + require.Len(t, req.IpxeTemplateParameters.Items, 1) + require.NotNil(t, req.IpxeTemplateArtifacts) + require.Len(t, req.IpxeTemplateArtifacts.Items, 1) + require.NotNil(t, req.IpxeTemplateDefinitionHash) + assert.Equal(t, "hash-1", *req.IpxeTemplateDefinitionHash) + }) + + t.Run("delete request maps id", func(t *testing.T) { + req := BuildDeleteOperatingSystemRequest(os) + require.NotNil(t, req) + assert.Equal(t, id.String(), req.GetId().GetValue()) + }) +} + +func TestNewAPIOperatingSystem_RedactsArtifactAuthToken(t *testing.T) { + authToken := "super-secret" + dbOS := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "templated", + Org: "org-1", + Type: cdbm.OperatingSystemTypeTemplatedIPXE, + IpxeTemplateId: cutil.GetPtr("tmpl-1"), + IpxeOsScope: cutil.GetPtr(cdbm.OperatingSystemScopeGlobal), + IpxeTemplateArtifacts: []cdbm.OperatingSystemIpxeArtifact{ + {Name: "kernel", URL: "http://x/k", AuthType: cutil.GetPtr(cdbm.OperatingSystemAuthTypeBearer), AuthToken: &authToken, CacheStrategy: cdbm.OperatingSystemIpxeArtifactCacheStrategyCacheAsNeeded}, + }, + } + + api := NewAPIOperatingSystem(dbOS, nil, nil, nil) + require.NotNil(t, api) + require.NotNil(t, api.Scope) + assert.Equal(t, cdbm.OperatingSystemScopeGlobal, *api.Scope) + require.Len(t, api.IpxeTemplateArtifacts, 1) + assert.Nil(t, api.IpxeTemplateArtifacts[0].AuthToken, "artifact authToken must be redacted in API responses") + // The source DB object must not be mutated by the redaction copy. + require.NotNil(t, dbOS.IpxeTemplateArtifacts[0].AuthToken) + assert.Equal(t, "super-secret", *dbOS.IpxeTemplateArtifacts[0].AuthToken) +} diff --git a/rest-api/api/pkg/api/routes.go b/rest-api/api/pkg/api/routes.go index 3eb0667d63..93f3398924 100644 --- a/rest-api/api/pkg/api/routes.go +++ b/rest-api/api/pkg/api/routes.go @@ -680,6 +680,17 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa Method: http.MethodDelete, Handler: apiHandler.NewDeleteOperatingSystemHandler(dbSession, tc, scp, cfg), }, + // iPXE Template endpoints (read-only; templates are synced from nico-core) + { + Path: apiPathPrefix + "/ipxe-template", + Method: http.MethodGet, + Handler: apiHandler.NewGetAllIpxeTemplateHandler(dbSession, tc, cfg), + }, + { + Path: apiPathPrefix + "/ipxe-template/:id", + Method: http.MethodGet, + Handler: apiHandler.NewGetIpxeTemplateHandler(dbSession, tc, cfg), + }, // NetworkSecurityGroup endpoints { Path: apiPathPrefix + "/network-security-group", diff --git a/rest-api/api/pkg/api/routes_test.go b/rest-api/api/pkg/api/routes_test.go index 308529e4e3..f66fd98a52 100644 --- a/rest-api/api/pkg/api/routes_test.go +++ b/rest-api/api/pkg/api/routes_test.go @@ -62,6 +62,7 @@ func TestNewAPIRoutes(t *testing.T) { "machine-instance-type": 3, "user": 1, "operating-system": 5, + "ipxe-template": 2, "sshkey": 5, "sshkeygroup": 5, "machine-capability": 1, diff --git a/rest-api/openapi/spec.yaml b/rest-api/openapi/spec.yaml index a01aff4873..73320780da 100644 --- a/rest-api/openapi/spec.yaml +++ b/rest-api/openapi/spec.yaml @@ -11295,6 +11295,88 @@ paths: $ref: '#/components/responses/NotFoundError' tags: - Tray + '/v2/org/{org}/nico/ipxe-template': + parameters: + - schema: + type: string + name: org + in: path + required: true + description: Name of the Org + get: + summary: Get all iPXE templates + description: 'Get all iPXE templates propagated from nico-core. Optionally restrict to one or more sites with the siteId query parameter.' + operationId: get-all-ipxe-template + tags: + - iPXE Template + parameters: + - schema: + type: array + items: + type: string + format: uuid + name: siteId + in: query + required: false + description: 'Optional site ID(s); may be repeated to restrict results to templates available at any of the sites' + - schema: + type: integer + name: pageNumber + in: query + required: false + - schema: + type: integer + name: pageSize + in: query + required: false + - schema: + type: string + name: orderBy + in: query + required: false + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/IpxeTemplate' + '403': + $ref: '#/components/responses/ForbiddenError' + '/v2/org/{org}/nico/ipxe-template/{ipxeTemplateId}': + parameters: + - schema: + type: string + name: org + in: path + required: true + description: Name of the Org + - schema: + type: string + format: uuid + name: ipxeTemplateId + in: path + required: true + description: Stable template ID (UUID from core) + get: + summary: Retrieve an iPXE template + description: 'Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is available.' + operationId: get-ipxe-template + tags: + - iPXE Template + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/IpxeTemplate' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' '/v2/org/{org}/nico/network-security-group': parameters: - schema: @@ -15730,6 +15812,26 @@ components: - string - 'null' description: 'iPXE script or URL, only applicable for iPXE-based Operating System' + ipxeTemplateId: + type: + - string + - 'null' + description: 'ID of the iPXE template used, only present for Templated iPXE Operating System' + ipxeTemplateParameters: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeParameter' + description: 'Parameters passed to the iPXE template (Templated iPXE only)' + ipxeTemplateArtifacts: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeArtifact' + description: 'Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted.' + scope: + type: + - string + - 'null' + description: 'Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited)' userData: type: - string @@ -15787,6 +15889,92 @@ components: - Deleting - Error - Deactivated + OperatingSystemIpxeParameter: + title: OperatingSystemIpxeParameter + type: object + description: A name/value parameter passed to an iPXE template + properties: + name: + type: string + description: Parameter name (used as a variable in the template) + value: + type: string + description: Parameter value + OperatingSystemIpxeArtifact: + title: OperatingSystemIpxeArtifact + type: object + description: An artifact (kernel, initrd, ISO, ...) referenced by an iPXE OS definition + properties: + name: + type: string + description: Artifact name + url: + type: string + description: Original URL for the artifact + sha: + type: + - string + - 'null' + description: Optional SHA256 checksum + authType: + type: + - string + - 'null' + description: 'Optional auth type: Basic or Bearer' + authToken: + type: + - string + - 'null' + description: 'Optional auth token. Redacted in API responses.' + cacheStrategy: + type: string + enum: + - CacheAsNeeded + - LocalOnly + - CachedOnly + - RemoteOnly + description: How to handle caching for this artifact + IpxeTemplate: + title: IpxeTemplate + type: object + description: An iPXE script template propagated (read-only) from nico-core + properties: + id: + type: string + format: uuid + description: Stable template UUID assigned by core + name: + type: string + description: Globally unique template name + template: + type: string + description: Raw iPXE script content + requiredParams: + type: array + items: + type: string + description: Parameters that must be provided to render the template + reservedParams: + type: array + items: + type: string + description: Parameters reserved by the template and not user-supplied + requiredArtifacts: + type: array + items: + type: string + description: Artifact names required for the template + scope: + type: string + description: 'Template visibility: Internal or Public' + created: + type: string + format: date-time + readOnly: true + updated: + type: string + format: date-time + readOnly: true OperatingSystemSiteAssociation: title: OperatingSystemSiteAssociation type: object @@ -15961,6 +16149,31 @@ components: allowOverride: type: boolean description: Indicates if the user data can be overridden at Instance creation time + ipxeTemplateId: + type: + - string + - 'null' + description: 'ID of the iPXE template to use; identifies a Templated iPXE Operating System. Mutually exclusive with ipxeScript and imageUrl.' + ipxeTemplateParameters: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeParameter' + description: 'Parameters passed to the iPXE template (Templated iPXE only).' + ipxeTemplateArtifacts: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeArtifact' + description: 'Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only).' + scope: + type: + - string + - 'null' + enum: + - Local + - Global + - Limited + - null + description: 'Synchronization scope for iPXE-based Operating Systems. Required for Templated iPXE (Global or Limited; Local is created only in nico-core).' required: - name OperatingSystemUpdateRequest: @@ -16068,6 +16281,26 @@ components: - string - 'null' description: Optional deactivation note if OS is inactive + ipxeTemplateId: + type: + - string + - 'null' + description: 'ID of the iPXE template to use (Templated iPXE only). Mutually exclusive with ipxeScript and imageUrl.' + ipxeTemplateParameters: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeParameter' + description: 'Parameters passed to the iPXE template (Templated iPXE only).' + ipxeTemplateArtifacts: + type: array + items: + $ref: '#/components/schemas/OperatingSystemIpxeArtifact' + description: 'Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only).' + scope: + type: + - string + - 'null' + description: 'Scope is immutable after creation; if provided the request is rejected.' InstanceType: title: InstanceType type: object diff --git a/rest-api/site-agent/pkg/components/managers/operatingsystem/cron.go b/rest-api/site-agent/pkg/components/managers/operatingsystem/cron.go index cb9d3339e8..097af33450 100644 --- a/rest-api/site-agent/pkg/components/managers/operatingsystem/cron.go +++ b/rest-api/site-agent/pkg/components/managers/operatingsystem/cron.go @@ -22,19 +22,31 @@ const ( InventoryDefaultSchedule = "@every 3m" ) -// RegisterCron - Register Cron +// RegisterCron registers the OsImage, OperatingSystem, and iPXE template inventory +// discovery crons. func (api *API) RegisterCron() error { - // Validate the OS Image config later - ManagerAccess.Data.EB.Log.Info().Msg("OS Image: Registering Inventory Collect/Publish cron") + if err := api.registerInventoryCron("OS Image", "inventory-os-image-", sww.DiscoverOsImageInventory); err != nil { + return err + } + if err := api.registerInventoryCron("OperatingSystem", "inventory-operating-system-", sww.DiscoverOperatingSystemInventory); err != nil { + return err + } + return api.registerInventoryCron("iPXE Template", "inventory-ipxe-template-", sww.DiscoverIpxeTemplateInventory) +} + +// registerInventoryCron schedules a periodic inventory discovery workflow on the +// subscribe queue. +func (api *API) registerInventoryCron(label, workflowIDPrefix string, workflowFn interface{}) error { + ManagerAccess.Data.EB.Log.Info().Msgf("%s: Registering Inventory Collect/Publish cron", label) - workflowID := "inventory-os-image-" + ManagerAccess.Conf.EB.Temporal.TemporalSubscribeNamespace + workflowID := workflowIDPrefix + ManagerAccess.Conf.EB.Temporal.TemporalSubscribeNamespace cronSchedule := InventoryDefaultSchedule if ManagerAccess.Conf.EB.Temporal.TemporalInventorySchedule != "" { cronSchedule = ManagerAccess.Conf.EB.Temporal.TemporalInventorySchedule } - ManagerAccess.Data.EB.Log.Info().Str("Schedule", cronSchedule).Msg("OS Image: Inventory Collect/Publish cron schedule") + ManagerAccess.Data.EB.Log.Info().Str("Schedule", cronSchedule).Msgf("%s: Inventory Collect/Publish cron schedule", label) workflowOptions := client.StartWorkflowOptions{ ID: workflowID, @@ -45,11 +57,11 @@ func (api *API) RegisterCron() error { we, err := ManagerAccess.Data.EB.Managers.Workflow.Temporal.Subscriber.ExecuteWorkflow( context.Background(), workflowOptions, - sww.DiscoverOsImageInventory, + workflowFn, ) if err != nil { - ManagerAccess.Data.EB.Log.Error().Err(err).Msg("OS Image: Error registering Inventory Collect/Publish cron") + ManagerAccess.Data.EB.Log.Error().Err(err).Msgf("%s: Error registering Inventory Collect/Publish cron", label) return err } @@ -58,7 +70,7 @@ func (api *API) RegisterCron() error { wid = we.GetID() } - ManagerAccess.Data.EB.Log.Info().Interface("Workflow ID", wid).Msg("OS Image: successfully registered Inventory Collect/Publish cron") + ManagerAccess.Data.EB.Log.Info().Interface("Workflow ID", wid).Msgf("%s: successfully registered Inventory Collect/Publish cron", label) return nil } diff --git a/rest-api/site-agent/pkg/components/managers/operatingsystem/publisher.go b/rest-api/site-agent/pkg/components/managers/operatingsystem/publisher.go index 1a958b46a3..5f5b3073f3 100644 --- a/rest-api/site-agent/pkg/components/managers/operatingsystem/publisher.go +++ b/rest-api/site-agent/pkg/components/managers/operatingsystem/publisher.go @@ -31,6 +31,34 @@ func (api *API) RegisterPublisher() error { ManagerAccess.Data.EB.Managers.Workflow.Temporal.Worker.RegisterActivity(osImageInventoryManager.DiscoverOsImageInventory) ManagerAccess.Data.EB.Log.Info().Msg("OperatingSystem: Successfully registered DiscoverOsImageInventory activity") + // Register DiscoverOperatingSystemInventory workflow + activity (iPXE / Templated + // iPXE OS definitions collected from nico-core and published to the cloud). + ManagerAccess.Data.EB.Managers.Workflow.Temporal.Worker.RegisterWorkflow(sww.DiscoverOperatingSystemInventory) + ManagerAccess.Data.EB.Log.Info().Msg("OperatingSystem: Successfully registered DiscoverOperatingSystemInventory workflow") + + operatingSystemInventoryManager := swa.NewManageOperatingSystemInventory(swa.ManageInventoryConfig{ + SiteID: uuid.MustParse(ManagerAccess.Conf.EB.Temporal.ClusterID), + CoreGrpcAtomicClient: ManagerAccess.Data.EB.Managers.CoreGrpc.Client, + TemporalPublishClient: ManagerAccess.Data.EB.Managers.Workflow.Temporal.Publisher, + TemporalPublishQueue: ManagerAccess.Conf.EB.Temporal.TemporalPublishQueue, + }) + ManagerAccess.Data.EB.Managers.Workflow.Temporal.Worker.RegisterActivity(operatingSystemInventoryManager.DiscoverOperatingSystemInventory) + ManagerAccess.Data.EB.Log.Info().Msg("OperatingSystem: Successfully registered DiscoverOperatingSystemInventory activity") + + // Register DiscoverIpxeTemplateInventory workflow + activity (PUBLIC iPXE templates + // collected from nico-core and published to the cloud). + ManagerAccess.Data.EB.Managers.Workflow.Temporal.Worker.RegisterWorkflow(sww.DiscoverIpxeTemplateInventory) + ManagerAccess.Data.EB.Log.Info().Msg("OperatingSystem: Successfully registered DiscoverIpxeTemplateInventory workflow") + + ipxeTemplateInventoryManager := swa.NewManageIpxeTemplateInventory(swa.ManageInventoryConfig{ + SiteID: uuid.MustParse(ManagerAccess.Conf.EB.Temporal.ClusterID), + CoreGrpcAtomicClient: ManagerAccess.Data.EB.Managers.CoreGrpc.Client, + TemporalPublishClient: ManagerAccess.Data.EB.Managers.Workflow.Temporal.Publisher, + TemporalPublishQueue: ManagerAccess.Conf.EB.Temporal.TemporalPublishQueue, + }) + ManagerAccess.Data.EB.Managers.Workflow.Temporal.Worker.RegisterActivity(ipxeTemplateInventoryManager.DiscoverIpxeTemplateInventory) + ManagerAccess.Data.EB.Log.Info().Msg("OperatingSystem: Successfully registered DiscoverIpxeTemplateInventory activity") + api.RegisterCron() return nil } diff --git a/rest-api/site-workflow/pkg/activity/ipxetemplate.go b/rest-api/site-workflow/pkg/activity/ipxetemplate.go new file mode 100644 index 0000000000..9a2426072d --- /dev/null +++ b/rest-api/site-workflow/pkg/activity/ipxetemplate.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package activity + +import ( + "context" + "fmt" + + cClient "github.com/NVIDIA/infra-controller/rest-api/site-workflow/pkg/grpc/client" + cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" + "github.com/rs/zerolog/log" + tClient "go.temporal.io/sdk/client" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// ManageIpxeTemplateInventory is an activity wrapper for iPXE template inventory collection +// and publishing (inbound pull path: nico-core -> cloud). +type ManageIpxeTemplateInventory struct { + config ManageInventoryConfig +} + +// NewManageIpxeTemplateInventory returns a ManageIpxeTemplateInventory activity +func NewManageIpxeTemplateInventory(config ManageInventoryConfig) ManageIpxeTemplateInventory { + return ManageIpxeTemplateInventory{ + config: config, + } +} + +// DiscoverIpxeTemplateInventory collects iPXE template inventory from the Site Controller +// and publishes it to the cloud Temporal queue. Only PUBLIC templates are propagated to +// REST (core is the source of truth; one-way sync). +func (mii *ManageIpxeTemplateInventory) DiscoverIpxeTemplateInventory(ctx context.Context) error { + logger := log.With().Str("Activity", "DiscoverIpxeTemplateInventory").Logger() + logger.Info().Msg("Starting activity") + + workflowOptions := tClient.StartWorkflowOptions{ + ID: fmt.Sprintf("update-ipxe-template-inventory-%s", mii.config.SiteID.String()), + TaskQueue: mii.config.TemporalPublishQueue, + } + workflowName := "UpdateIpxeTemplateInventory" + + coreGrpcClient := mii.config.CoreGrpcAtomicClient.GetClient() + if coreGrpcClient == nil { + return cClient.ErrCoreGrpcClientNotConnected + } + forgeClient := coreGrpcClient.GrpcServiceClient() + + result, err := forgeClient.ListIpxeTemplates(ctx, &cwssaws.ListIpxeTemplatesRequest{}) + if err != nil { + logger.Warn().Err(err).Msg("Failed to retrieve iPXE templates from Site Controller") + inventory := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_FAILED, + StatusMsg: err.Error(), + Timestamp: timestamppb.Now(), + } + if _, execErr := mii.config.TemporalPublishClient.ExecuteWorkflow(ctx, workflowOptions, workflowName, mii.config.SiteID, inventory); execErr != nil { + logger.Error().Err(execErr).Msg("Failed to publish inventory error to Cloud") + return execErr + } + return err + } + + // Only propagate PUBLIC templates to REST (core is source of truth, one-way sync). + var publicTemplates []*cwssaws.IpxeTemplate + for _, t := range result.Templates { + if t.Scope == cwssaws.IpxeTemplateScope_PUBLIC { + publicTemplates = append(publicTemplates, t) + } + } + + inventory := &cwssaws.IpxeTemplateInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + StatusMsg: "Successfully retrieved from Site Controller", + Timestamp: timestamppb.Now(), + Templates: publicTemplates, + } + + if _, err = mii.config.TemporalPublishClient.ExecuteWorkflow(ctx, workflowOptions, workflowName, mii.config.SiteID, inventory); err != nil { + logger.Error().Err(err).Msg("Failed to publish iPXE template inventory to Cloud") + return err + } + + logger.Info().Msgf("Published %d public iPXE templates to Cloud (filtered from %d total)", len(publicTemplates), len(result.Templates)) + logger.Info().Msg("Completed activity") + return nil +} diff --git a/rest-api/site-workflow/pkg/activity/operatingsystem.go b/rest-api/site-workflow/pkg/activity/operatingsystem.go index f1ec8e2926..2e842714c6 100644 --- a/rest-api/site-workflow/pkg/activity/operatingsystem.go +++ b/rest-api/site-workflow/pkg/activity/operatingsystem.go @@ -6,12 +6,14 @@ package activity import ( "context" "errors" + "fmt" "time" swe "github.com/NVIDIA/infra-controller/rest-api/site-workflow/pkg/error" cClient "github.com/NVIDIA/infra-controller/rest-api/site-workflow/pkg/grpc/client" cwssaws "github.com/NVIDIA/infra-controller/rest-api/workflow-schema/schema/site-agent/workflows/v1" "github.com/rs/zerolog/log" + tClient "go.temporal.io/sdk/client" "go.temporal.io/sdk/temporal" "google.golang.org/protobuf/types/known/timestamppb" @@ -222,3 +224,85 @@ func osImageFindFallback(ctx context.Context, grpcClient *cClient.CoreGrpcClient } return ids, items.GetImages(), nil } + +// ManageOperatingSystemInventory is an activity wrapper for Operating System (iPXE / +// Templated iPXE definition) inventory collection and publishing. This is the inbound +// (pull) path: it reads OS definitions from on-site nico-core and publishes them to the +// cloud for reconciliation with the operating_system table. Outbound pushes are handled +// by the generic Core gRPC proxy, not here. +type ManageOperatingSystemInventory struct { + config ManageInventoryConfig +} + +// NewManageOperatingSystemInventory returns a ManageOperatingSystemInventory activity +func NewManageOperatingSystemInventory(config ManageInventoryConfig) ManageOperatingSystemInventory { + return ManageOperatingSystemInventory{config: config} +} + +// DiscoverOperatingSystemInventory collects Operating System inventory from nico-core and +// publishes it to the cloud Temporal queue for reconciliation with the operating_system table. +func (m *ManageOperatingSystemInventory) DiscoverOperatingSystemInventory(ctx context.Context) error { + logger := log.With().Str("Activity", "DiscoverOperatingSystemInventory").Logger() + logger.Info().Msg("Starting activity") + + workflowOptions := tClient.StartWorkflowOptions{ + ID: fmt.Sprintf("update-operating-system-inventory-%s", m.config.SiteID.String()), + TaskQueue: m.config.TemporalPublishQueue, + } + workflowName := "UpdateOperatingSystemInventory" + + coreGrpcClient := m.config.CoreGrpcAtomicClient.GetClient() + if coreGrpcClient == nil { + return cClient.ErrCoreGrpcClientNotConnected + } + forgeClient := coreGrpcClient.GrpcServiceClient() + + publishError := func(cause error) error { + inv := &cwssaws.OperatingSystemInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_FAILED, + StatusMsg: cause.Error(), + Timestamp: timestamppb.Now(), + } + if _, execErr := m.config.TemporalPublishClient.ExecuteWorkflow(context.Background(), workflowOptions, workflowName, m.config.SiteID, inv); execErr != nil { + logger.Error().Err(execErr).Msg("Failed to publish inventory error to Cloud") + return execErr + } + return cause + } + + // Step 1: fetch all active OS definition IDs from nico-core. + idList, err := forgeClient.FindOperatingSystemIds(ctx, &cwssaws.OperatingSystemSearchFilter{}) + if err != nil { + logger.Warn().Err(err).Msg("Failed to retrieve OS definition IDs from nico-core") + return publishError(err) + } + + // Step 2: fetch full definitions for all returned IDs. + var osDefs []*cwssaws.OperatingSystem + if len(idList.GetIds()) > 0 { + osList, ferr := forgeClient.FindOperatingSystemsByIds(ctx, &cwssaws.OperatingSystemsByIdsRequest{ + Ids: idList.GetIds(), + }) + if ferr != nil { + logger.Warn().Err(ferr).Msg("Failed to retrieve OS definitions by IDs from nico-core") + return publishError(ferr) + } + osDefs = osList.GetOperatingSystems() + } + + inventory := &cwssaws.OperatingSystemInventory{ + InventoryStatus: cwssaws.InventoryStatus_INVENTORY_STATUS_SUCCESS, + StatusMsg: "Successfully retrieved from nico-core", + Timestamp: timestamppb.Now(), + OperatingSystems: osDefs, + } + + if _, err = m.config.TemporalPublishClient.ExecuteWorkflow(context.Background(), workflowOptions, workflowName, m.config.SiteID, inventory); err != nil { + logger.Error().Err(err).Msg("Failed to publish OS definition inventory to Cloud") + return err + } + + logger.Info().Msgf("Published %d Operating Systems to Cloud", len(osDefs)) + logger.Info().Msg("Completed activity") + return nil +} diff --git a/rest-api/site-workflow/pkg/workflow/ipxetemplate.go b/rest-api/site-workflow/pkg/workflow/ipxetemplate.go new file mode 100644 index 0000000000..b7336c1b70 --- /dev/null +++ b/rest-api/site-workflow/pkg/workflow/ipxetemplate.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package workflow + +import ( + "time" + + "github.com/NVIDIA/infra-controller/rest-api/site-workflow/pkg/activity" + "github.com/rs/zerolog/log" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +// DiscoverIpxeTemplateInventory is a workflow that triggers iPXE template inventory +// collection from the Site Controller and publishes it to the cloud. +func DiscoverIpxeTemplateInventory(ctx workflow.Context) error { + logger := log.With().Str("Workflow", "DiscoverIpxeTemplateInventory").Logger() + logger.Info().Msg("Starting workflow") + + retrypolicy := &temporal.RetryPolicy{ + InitialInterval: 2 * time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: 10 * time.Second, + // Executed every 3 minutes, so we don't want too many retry attempts + MaximumAttempts: 2, + } + options := workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Minute, + RetryPolicy: retrypolicy, + } + + ctx = workflow.WithActivityOptions(ctx, options) + + var inventoryManager activity.ManageIpxeTemplateInventory + + err := workflow.ExecuteActivity(ctx, inventoryManager.DiscoverIpxeTemplateInventory).Get(ctx, nil) + if err != nil { + logger.Error().Err(err).Str("Activity", "DiscoverIpxeTemplateInventory").Msg("Failed to execute activity from workflow") + return err + } + + logger.Info().Msg("Completing workflow") + return nil +} diff --git a/rest-api/site-workflow/pkg/workflow/operatingsystem.go b/rest-api/site-workflow/pkg/workflow/operatingsystem.go index e90e699bdd..13598dba0f 100644 --- a/rest-api/site-workflow/pkg/workflow/operatingsystem.go +++ b/rest-api/site-workflow/pkg/workflow/operatingsystem.go @@ -158,3 +158,34 @@ func DiscoverOsImageInventory(ctx workflow.Context) error { return nil } + +// DiscoverOperatingSystemInventory triggers Operating System (iPXE / Templated iPXE +// definition) inventory collection from nico-core and publishes it to the cloud for +// reconciliation with the operating_system table. +func DiscoverOperatingSystemInventory(ctx workflow.Context) error { + logger := log.With().Str("Workflow", "DiscoverOperatingSystemInventory").Logger() + logger.Info().Msg("Starting workflow") + + retrypolicy := &temporal.RetryPolicy{ + InitialInterval: 2 * time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: 10 * time.Second, + MaximumAttempts: 2, + } + options := workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Minute, + RetryPolicy: retrypolicy, + } + ctx = workflow.WithActivityOptions(ctx, options) + + var inventoryManager activity.ManageOperatingSystemInventory + + err := workflow.ExecuteActivity(ctx, inventoryManager.DiscoverOperatingSystemInventory).Get(ctx, nil) + if err != nil { + logger.Error().Err(err).Str("Activity", "DiscoverOperatingSystemInventory").Msg("Failed to execute activity from workflow") + return err + } + + logger.Info().Msg("Completing workflow") + return nil +} From b6da5ff56b6d6c9ff5c83f12f7ad377db5a24fe0 Mon Sep 17 00:00:00 2001 From: Kyle Felter Date: Wed, 24 Jun 2026 11:26:33 -0500 Subject: [PATCH 5/5] chore: Regenerate Go SDK for Templated iPXE operating system endpoints Signed-off-by: Kyle Felter --- rest-api/sdk/standard/api_ipxe_template.go | 312 +++++++++++++ rest-api/sdk/standard/client.go | 3 + rest-api/sdk/standard/model_ipxe_template.go | 420 ++++++++++++++++++ .../sdk/standard/model_operating_system.go | 170 +++++++ .../model_operating_system_create_request.go | 170 +++++++ .../model_operating_system_ipxe_artifact.go | 343 ++++++++++++++ .../model_operating_system_ipxe_parameter.go | 162 +++++++ .../model_operating_system_update_request.go | 170 +++++++ 8 files changed, 1750 insertions(+) create mode 100644 rest-api/sdk/standard/api_ipxe_template.go create mode 100644 rest-api/sdk/standard/model_ipxe_template.go create mode 100644 rest-api/sdk/standard/model_operating_system_ipxe_artifact.go create mode 100644 rest-api/sdk/standard/model_operating_system_ipxe_parameter.go diff --git a/rest-api/sdk/standard/api_ipxe_template.go b/rest-api/sdk/standard/api_ipxe_template.go new file mode 100644 index 0000000000..7832585af0 --- /dev/null +++ b/rest-api/sdk/standard/api_ipxe_template.go @@ -0,0 +1,312 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "reflect" + "strings" +) + +// IPXETemplateAPIService IPXETemplateAPI service +type IPXETemplateAPIService service + +type ApiGetAllIpxeTemplateRequest struct { + ctx context.Context + ApiService *IPXETemplateAPIService + org string + siteId *[]string + pageNumber *int32 + pageSize *int32 + orderBy *string +} + +// Optional site ID(s); may be repeated to restrict results to templates available at any of the sites +func (r ApiGetAllIpxeTemplateRequest) SiteId(siteId []string) ApiGetAllIpxeTemplateRequest { + r.siteId = &siteId + return r +} + +func (r ApiGetAllIpxeTemplateRequest) PageNumber(pageNumber int32) ApiGetAllIpxeTemplateRequest { + r.pageNumber = &pageNumber + return r +} + +func (r ApiGetAllIpxeTemplateRequest) PageSize(pageSize int32) ApiGetAllIpxeTemplateRequest { + r.pageSize = &pageSize + return r +} + +func (r ApiGetAllIpxeTemplateRequest) OrderBy(orderBy string) ApiGetAllIpxeTemplateRequest { + r.orderBy = &orderBy + return r +} + +func (r ApiGetAllIpxeTemplateRequest) Execute() ([]IpxeTemplate, *http.Response, error) { + return r.ApiService.GetAllIpxeTemplateExecute(r) +} + +/* +GetAllIpxeTemplate Get all iPXE templates + +Get all iPXE templates propagated from nico-core. Optionally restrict to one or more sites with the siteId query parameter. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Org + @return ApiGetAllIpxeTemplateRequest +*/ +func (a *IPXETemplateAPIService) GetAllIpxeTemplate(ctx context.Context, org string) ApiGetAllIpxeTemplateRequest { + return ApiGetAllIpxeTemplateRequest{ + ApiService: a, + ctx: ctx, + org: org, + } +} + +// Execute executes the request +// +// @return []IpxeTemplate +func (a *IPXETemplateAPIService) GetAllIpxeTemplateExecute(r ApiGetAllIpxeTemplateRequest) ([]IpxeTemplate, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue []IpxeTemplate + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IPXETemplateAPIService.GetAllIpxeTemplate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/ipxe-template" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.siteId != nil { + t := *r.siteId + if reflect.TypeOf(t).Kind() == reflect.Slice { + s := reflect.ValueOf(t) + for i := 0; i < s.Len(); i++ { + parameterAddToHeaderOrQuery(localVarQueryParams, "siteId", s.Index(i).Interface(), "form", "multi") + } + } else { + parameterAddToHeaderOrQuery(localVarQueryParams, "siteId", t, "form", "multi") + } + } + if r.pageNumber != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "pageNumber", r.pageNumber, "form", "") + } + if r.pageSize != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "pageSize", r.pageSize, "form", "") + } + if r.orderBy != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderBy", r.orderBy, "form", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiGetIpxeTemplateRequest struct { + ctx context.Context + ApiService *IPXETemplateAPIService + org string + ipxeTemplateId string +} + +func (r ApiGetIpxeTemplateRequest) Execute() (*IpxeTemplate, *http.Response, error) { + return r.ApiService.GetIpxeTemplateExecute(r) +} + +/* +GetIpxeTemplate Retrieve an iPXE template + +Retrieve an iPXE template by its stable core ID. The caller must be authorized for at least one Site at which the template is available. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Org + @param ipxeTemplateId Stable template ID (UUID from core) + @return ApiGetIpxeTemplateRequest +*/ +func (a *IPXETemplateAPIService) GetIpxeTemplate(ctx context.Context, org string, ipxeTemplateId string) ApiGetIpxeTemplateRequest { + return ApiGetIpxeTemplateRequest{ + ApiService: a, + ctx: ctx, + org: org, + ipxeTemplateId: ipxeTemplateId, + } +} + +// Execute executes the request +// +// @return IpxeTemplate +func (a *IPXETemplateAPIService) GetIpxeTemplateExecute(r ApiGetIpxeTemplateRequest) (*IpxeTemplate, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *IpxeTemplate + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IPXETemplateAPIService.GetIpxeTemplate") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/ipxe-template/{ipxeTemplateId}" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"ipxeTemplateId"+"}", url.PathEscape(parameterValueToString(r.ipxeTemplateId, "ipxeTemplateId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/rest-api/sdk/standard/client.go b/rest-api/sdk/standard/client.go index d631a2b8a9..f1446161ca 100644 --- a/rest-api/sdk/standard/client.go +++ b/rest-api/sdk/standard/client.go @@ -69,6 +69,8 @@ type APIClient struct { IPBlockAPI *IPBlockAPIService + IPXETemplateAPI *IPXETemplateAPIService + InfiniBandPartitionAPI *InfiniBandPartitionAPIService InfrastructureProviderAPI *InfrastructureProviderAPIService @@ -147,6 +149,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.ExpectedRackAPI = (*ExpectedRackAPIService)(&c.common) c.ExpectedSwitchAPI = (*ExpectedSwitchAPIService)(&c.common) c.IPBlockAPI = (*IPBlockAPIService)(&c.common) + c.IPXETemplateAPI = (*IPXETemplateAPIService)(&c.common) c.InfiniBandPartitionAPI = (*InfiniBandPartitionAPIService)(&c.common) c.InfrastructureProviderAPI = (*InfrastructureProviderAPIService)(&c.common) c.InstanceAPI = (*InstanceAPIService)(&c.common) diff --git a/rest-api/sdk/standard/model_ipxe_template.go b/rest-api/sdk/standard/model_ipxe_template.go new file mode 100644 index 0000000000..e319e83f8e --- /dev/null +++ b/rest-api/sdk/standard/model_ipxe_template.go @@ -0,0 +1,420 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" + "time" +) + +// checks if the IpxeTemplate type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &IpxeTemplate{} + +// IpxeTemplate An iPXE script template propagated (read-only) from nico-core +type IpxeTemplate struct { + // Stable template UUID assigned by core + Id *string `json:"id,omitempty"` + // Globally unique template name + Name *string `json:"name,omitempty"` + // Raw iPXE script content + Template *string `json:"template,omitempty"` + // Parameters that must be provided to render the template + RequiredParams []string `json:"requiredParams,omitempty"` + // Parameters reserved by the template and not user-supplied + ReservedParams []string `json:"reservedParams,omitempty"` + // Artifact names required for the template + RequiredArtifacts []string `json:"requiredArtifacts,omitempty"` + // Template visibility: Internal or Public + Scope *string `json:"scope,omitempty"` + Created *time.Time `json:"created,omitempty"` + Updated *time.Time `json:"updated,omitempty"` +} + +// NewIpxeTemplate instantiates a new IpxeTemplate object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewIpxeTemplate() *IpxeTemplate { + this := IpxeTemplate{} + return &this +} + +// NewIpxeTemplateWithDefaults instantiates a new IpxeTemplate object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewIpxeTemplateWithDefaults() *IpxeTemplate { + this := IpxeTemplate{} + return &this +} + +// GetId returns the Id field value if set, zero value otherwise. +func (o *IpxeTemplate) GetId() string { + if o == nil || IsNil(o.Id) { + var ret string + return ret + } + return *o.Id +} + +// GetIdOk returns a tuple with the Id field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetIdOk() (*string, bool) { + if o == nil || IsNil(o.Id) { + return nil, false + } + return o.Id, true +} + +// HasId returns a boolean if a field has been set. +func (o *IpxeTemplate) HasId() bool { + if o != nil && !IsNil(o.Id) { + return true + } + + return false +} + +// SetId gets a reference to the given string and assigns it to the Id field. +func (o *IpxeTemplate) SetId(v string) { + o.Id = &v +} + +// GetName returns the Name field value if set, zero value otherwise. +func (o *IpxeTemplate) GetName() string { + if o == nil || IsNil(o.Name) { + var ret string + return ret + } + return *o.Name +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetNameOk() (*string, bool) { + if o == nil || IsNil(o.Name) { + return nil, false + } + return o.Name, true +} + +// HasName returns a boolean if a field has been set. +func (o *IpxeTemplate) HasName() bool { + if o != nil && !IsNil(o.Name) { + return true + } + + return false +} + +// SetName gets a reference to the given string and assigns it to the Name field. +func (o *IpxeTemplate) SetName(v string) { + o.Name = &v +} + +// GetTemplate returns the Template field value if set, zero value otherwise. +func (o *IpxeTemplate) GetTemplate() string { + if o == nil || IsNil(o.Template) { + var ret string + return ret + } + return *o.Template +} + +// GetTemplateOk returns a tuple with the Template field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetTemplateOk() (*string, bool) { + if o == nil || IsNil(o.Template) { + return nil, false + } + return o.Template, true +} + +// HasTemplate returns a boolean if a field has been set. +func (o *IpxeTemplate) HasTemplate() bool { + if o != nil && !IsNil(o.Template) { + return true + } + + return false +} + +// SetTemplate gets a reference to the given string and assigns it to the Template field. +func (o *IpxeTemplate) SetTemplate(v string) { + o.Template = &v +} + +// GetRequiredParams returns the RequiredParams field value if set, zero value otherwise. +func (o *IpxeTemplate) GetRequiredParams() []string { + if o == nil || IsNil(o.RequiredParams) { + var ret []string + return ret + } + return o.RequiredParams +} + +// GetRequiredParamsOk returns a tuple with the RequiredParams field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetRequiredParamsOk() ([]string, bool) { + if o == nil || IsNil(o.RequiredParams) { + return nil, false + } + return o.RequiredParams, true +} + +// HasRequiredParams returns a boolean if a field has been set. +func (o *IpxeTemplate) HasRequiredParams() bool { + if o != nil && !IsNil(o.RequiredParams) { + return true + } + + return false +} + +// SetRequiredParams gets a reference to the given []string and assigns it to the RequiredParams field. +func (o *IpxeTemplate) SetRequiredParams(v []string) { + o.RequiredParams = v +} + +// GetReservedParams returns the ReservedParams field value if set, zero value otherwise. +func (o *IpxeTemplate) GetReservedParams() []string { + if o == nil || IsNil(o.ReservedParams) { + var ret []string + return ret + } + return o.ReservedParams +} + +// GetReservedParamsOk returns a tuple with the ReservedParams field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetReservedParamsOk() ([]string, bool) { + if o == nil || IsNil(o.ReservedParams) { + return nil, false + } + return o.ReservedParams, true +} + +// HasReservedParams returns a boolean if a field has been set. +func (o *IpxeTemplate) HasReservedParams() bool { + if o != nil && !IsNil(o.ReservedParams) { + return true + } + + return false +} + +// SetReservedParams gets a reference to the given []string and assigns it to the ReservedParams field. +func (o *IpxeTemplate) SetReservedParams(v []string) { + o.ReservedParams = v +} + +// GetRequiredArtifacts returns the RequiredArtifacts field value if set, zero value otherwise. +func (o *IpxeTemplate) GetRequiredArtifacts() []string { + if o == nil || IsNil(o.RequiredArtifacts) { + var ret []string + return ret + } + return o.RequiredArtifacts +} + +// GetRequiredArtifactsOk returns a tuple with the RequiredArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetRequiredArtifactsOk() ([]string, bool) { + if o == nil || IsNil(o.RequiredArtifacts) { + return nil, false + } + return o.RequiredArtifacts, true +} + +// HasRequiredArtifacts returns a boolean if a field has been set. +func (o *IpxeTemplate) HasRequiredArtifacts() bool { + if o != nil && !IsNil(o.RequiredArtifacts) { + return true + } + + return false +} + +// SetRequiredArtifacts gets a reference to the given []string and assigns it to the RequiredArtifacts field. +func (o *IpxeTemplate) SetRequiredArtifacts(v []string) { + o.RequiredArtifacts = v +} + +// GetScope returns the Scope field value if set, zero value otherwise. +func (o *IpxeTemplate) GetScope() string { + if o == nil || IsNil(o.Scope) { + var ret string + return ret + } + return *o.Scope +} + +// GetScopeOk returns a tuple with the Scope field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetScopeOk() (*string, bool) { + if o == nil || IsNil(o.Scope) { + return nil, false + } + return o.Scope, true +} + +// HasScope returns a boolean if a field has been set. +func (o *IpxeTemplate) HasScope() bool { + if o != nil && !IsNil(o.Scope) { + return true + } + + return false +} + +// SetScope gets a reference to the given string and assigns it to the Scope field. +func (o *IpxeTemplate) SetScope(v string) { + o.Scope = &v +} + +// GetCreated returns the Created field value if set, zero value otherwise. +func (o *IpxeTemplate) GetCreated() time.Time { + if o == nil || IsNil(o.Created) { + var ret time.Time + return ret + } + return *o.Created +} + +// GetCreatedOk returns a tuple with the Created field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetCreatedOk() (*time.Time, bool) { + if o == nil || IsNil(o.Created) { + return nil, false + } + return o.Created, true +} + +// HasCreated returns a boolean if a field has been set. +func (o *IpxeTemplate) HasCreated() bool { + if o != nil && !IsNil(o.Created) { + return true + } + + return false +} + +// SetCreated gets a reference to the given time.Time and assigns it to the Created field. +func (o *IpxeTemplate) SetCreated(v time.Time) { + o.Created = &v +} + +// GetUpdated returns the Updated field value if set, zero value otherwise. +func (o *IpxeTemplate) GetUpdated() time.Time { + if o == nil || IsNil(o.Updated) { + var ret time.Time + return ret + } + return *o.Updated +} + +// GetUpdatedOk returns a tuple with the Updated field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IpxeTemplate) GetUpdatedOk() (*time.Time, bool) { + if o == nil || IsNil(o.Updated) { + return nil, false + } + return o.Updated, true +} + +// HasUpdated returns a boolean if a field has been set. +func (o *IpxeTemplate) HasUpdated() bool { + if o != nil && !IsNil(o.Updated) { + return true + } + + return false +} + +// SetUpdated gets a reference to the given time.Time and assigns it to the Updated field. +func (o *IpxeTemplate) SetUpdated(v time.Time) { + o.Updated = &v +} + +func (o IpxeTemplate) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o IpxeTemplate) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Id) { + toSerialize["id"] = o.Id + } + if !IsNil(o.Name) { + toSerialize["name"] = o.Name + } + if !IsNil(o.Template) { + toSerialize["template"] = o.Template + } + if !IsNil(o.RequiredParams) { + toSerialize["requiredParams"] = o.RequiredParams + } + if !IsNil(o.ReservedParams) { + toSerialize["reservedParams"] = o.ReservedParams + } + if !IsNil(o.RequiredArtifacts) { + toSerialize["requiredArtifacts"] = o.RequiredArtifacts + } + if !IsNil(o.Scope) { + toSerialize["scope"] = o.Scope + } + if !IsNil(o.Created) { + toSerialize["created"] = o.Created + } + if !IsNil(o.Updated) { + toSerialize["updated"] = o.Updated + } + return toSerialize, nil +} + +type NullableIpxeTemplate struct { + value *IpxeTemplate + isSet bool +} + +func (v NullableIpxeTemplate) Get() *IpxeTemplate { + return v.value +} + +func (v *NullableIpxeTemplate) Set(val *IpxeTemplate) { + v.value = val + v.isSet = true +} + +func (v NullableIpxeTemplate) IsSet() bool { + return v.isSet +} + +func (v *NullableIpxeTemplate) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableIpxeTemplate(val *IpxeTemplate) *NullableIpxeTemplate { + return &NullableIpxeTemplate{value: val, isSet: true} +} + +func (v NullableIpxeTemplate) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableIpxeTemplate) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_operating_system.go b/rest-api/sdk/standard/model_operating_system.go index 204098756c..c2f9d7dcda 100644 --- a/rest-api/sdk/standard/model_operating_system.go +++ b/rest-api/sdk/standard/model_operating_system.go @@ -51,6 +51,14 @@ type OperatingSystem struct { RootFsLabel NullableString `json:"rootFsLabel,omitempty"` // iPXE script or URL, only applicable for iPXE-based Operating System IpxeScript NullableString `json:"ipxeScript,omitempty"` + // ID of the iPXE template used, only present for Templated iPXE Operating System + IpxeTemplateId NullableString `json:"ipxeTemplateId,omitempty"` + // Parameters passed to the iPXE template (Templated iPXE only) + IpxeTemplateParameters []OperatingSystemIpxeParameter `json:"ipxeTemplateParameters,omitempty"` + // Artifacts for the iPXE OS definition (Templated iPXE only). authToken is redacted. + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts,omitempty"` + // Synchronization scope for iPXE-based Operating Systems (Local, Global, or Limited) + Scope NullableString `json:"scope,omitempty"` // User data for the Operating System UserData NullableString `json:"userData,omitempty"` // Specified when the Operating System is cloud-init based @@ -672,6 +680,156 @@ func (o *OperatingSystem) UnsetIpxeScript() { o.IpxeScript.Unset() } +// GetIpxeTemplateId returns the IpxeTemplateId field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystem) GetIpxeTemplateId() string { + if o == nil || IsNil(o.IpxeTemplateId.Get()) { + var ret string + return ret + } + return *o.IpxeTemplateId.Get() +} + +// GetIpxeTemplateIdOk returns a tuple with the IpxeTemplateId field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystem) GetIpxeTemplateIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.IpxeTemplateId.Get(), o.IpxeTemplateId.IsSet() +} + +// HasIpxeTemplateId returns a boolean if a field has been set. +func (o *OperatingSystem) HasIpxeTemplateId() bool { + if o != nil && o.IpxeTemplateId.IsSet() { + return true + } + + return false +} + +// SetIpxeTemplateId gets a reference to the given NullableString and assigns it to the IpxeTemplateId field. +func (o *OperatingSystem) SetIpxeTemplateId(v string) { + o.IpxeTemplateId.Set(&v) +} + +// SetIpxeTemplateIdNil sets the value for IpxeTemplateId to be an explicit nil +func (o *OperatingSystem) SetIpxeTemplateIdNil() { + o.IpxeTemplateId.Set(nil) +} + +// UnsetIpxeTemplateId ensures that no value is present for IpxeTemplateId, not even an explicit nil +func (o *OperatingSystem) UnsetIpxeTemplateId() { + o.IpxeTemplateId.Unset() +} + +// GetIpxeTemplateParameters returns the IpxeTemplateParameters field value if set, zero value otherwise. +func (o *OperatingSystem) GetIpxeTemplateParameters() []OperatingSystemIpxeParameter { + if o == nil || IsNil(o.IpxeTemplateParameters) { + var ret []OperatingSystemIpxeParameter + return ret + } + return o.IpxeTemplateParameters +} + +// GetIpxeTemplateParametersOk returns a tuple with the IpxeTemplateParameters field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystem) GetIpxeTemplateParametersOk() ([]OperatingSystemIpxeParameter, bool) { + if o == nil || IsNil(o.IpxeTemplateParameters) { + return nil, false + } + return o.IpxeTemplateParameters, true +} + +// HasIpxeTemplateParameters returns a boolean if a field has been set. +func (o *OperatingSystem) HasIpxeTemplateParameters() bool { + if o != nil && !IsNil(o.IpxeTemplateParameters) { + return true + } + + return false +} + +// SetIpxeTemplateParameters gets a reference to the given []OperatingSystemIpxeParameter and assigns it to the IpxeTemplateParameters field. +func (o *OperatingSystem) SetIpxeTemplateParameters(v []OperatingSystemIpxeParameter) { + o.IpxeTemplateParameters = v +} + +// GetIpxeTemplateArtifacts returns the IpxeTemplateArtifacts field value if set, zero value otherwise. +func (o *OperatingSystem) GetIpxeTemplateArtifacts() []OperatingSystemIpxeArtifact { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + var ret []OperatingSystemIpxeArtifact + return ret + } + return o.IpxeTemplateArtifacts +} + +// GetIpxeTemplateArtifactsOk returns a tuple with the IpxeTemplateArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystem) GetIpxeTemplateArtifactsOk() ([]OperatingSystemIpxeArtifact, bool) { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + return nil, false + } + return o.IpxeTemplateArtifacts, true +} + +// HasIpxeTemplateArtifacts returns a boolean if a field has been set. +func (o *OperatingSystem) HasIpxeTemplateArtifacts() bool { + if o != nil && !IsNil(o.IpxeTemplateArtifacts) { + return true + } + + return false +} + +// SetIpxeTemplateArtifacts gets a reference to the given []OperatingSystemIpxeArtifact and assigns it to the IpxeTemplateArtifacts field. +func (o *OperatingSystem) SetIpxeTemplateArtifacts(v []OperatingSystemIpxeArtifact) { + o.IpxeTemplateArtifacts = v +} + +// GetScope returns the Scope field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystem) GetScope() string { + if o == nil || IsNil(o.Scope.Get()) { + var ret string + return ret + } + return *o.Scope.Get() +} + +// GetScopeOk returns a tuple with the Scope field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystem) GetScopeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Scope.Get(), o.Scope.IsSet() +} + +// HasScope returns a boolean if a field has been set. +func (o *OperatingSystem) HasScope() bool { + if o != nil && o.Scope.IsSet() { + return true + } + + return false +} + +// SetScope gets a reference to the given NullableString and assigns it to the Scope field. +func (o *OperatingSystem) SetScope(v string) { + o.Scope.Set(&v) +} + +// SetScopeNil sets the value for Scope to be an explicit nil +func (o *OperatingSystem) SetScopeNil() { + o.Scope.Set(nil) +} + +// UnsetScope ensures that no value is present for Scope, not even an explicit nil +func (o *OperatingSystem) UnsetScope() { + o.Scope.Unset() +} + // GetUserData returns the UserData field value if set, zero value otherwise (both if not set or set to explicit null). func (o *OperatingSystem) GetUserData() string { if o == nil || IsNil(o.UserData.Get()) { @@ -1098,6 +1256,18 @@ func (o OperatingSystem) ToMap() (map[string]interface{}, error) { if o.IpxeScript.IsSet() { toSerialize["ipxeScript"] = o.IpxeScript.Get() } + if o.IpxeTemplateId.IsSet() { + toSerialize["ipxeTemplateId"] = o.IpxeTemplateId.Get() + } + if !IsNil(o.IpxeTemplateParameters) { + toSerialize["ipxeTemplateParameters"] = o.IpxeTemplateParameters + } + if !IsNil(o.IpxeTemplateArtifacts) { + toSerialize["ipxeTemplateArtifacts"] = o.IpxeTemplateArtifacts + } + if o.Scope.IsSet() { + toSerialize["scope"] = o.Scope.Get() + } if o.UserData.IsSet() { toSerialize["userData"] = o.UserData.Get() } diff --git a/rest-api/sdk/standard/model_operating_system_create_request.go b/rest-api/sdk/standard/model_operating_system_create_request.go index 7b3adbce65..5af0789018 100644 --- a/rest-api/sdk/standard/model_operating_system_create_request.go +++ b/rest-api/sdk/standard/model_operating_system_create_request.go @@ -60,6 +60,14 @@ type OperatingSystemCreateRequest struct { IsCloudInit *bool `json:"isCloudInit,omitempty"` // Indicates if the user data can be overridden at Instance creation time AllowOverride *bool `json:"allowOverride,omitempty"` + // ID of the iPXE template to use; identifies a Templated iPXE Operating System. Mutually exclusive with ipxeScript and imageUrl. + IpxeTemplateId NullableString `json:"ipxeTemplateId,omitempty"` + // Parameters passed to the iPXE template (Templated iPXE only). + IpxeTemplateParameters []OperatingSystemIpxeParameter `json:"ipxeTemplateParameters,omitempty"` + // Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only). + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts,omitempty"` + // Synchronization scope for iPXE-based Operating Systems. Required for Templated iPXE (Global or Limited; Local is created only in nico-core). + Scope NullableString `json:"scope,omitempty"` } type _OperatingSystemCreateRequest OperatingSystemCreateRequest @@ -767,6 +775,156 @@ func (o *OperatingSystemCreateRequest) SetAllowOverride(v bool) { o.AllowOverride = &v } +// GetIpxeTemplateId returns the IpxeTemplateId field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemCreateRequest) GetIpxeTemplateId() string { + if o == nil || IsNil(o.IpxeTemplateId.Get()) { + var ret string + return ret + } + return *o.IpxeTemplateId.Get() +} + +// GetIpxeTemplateIdOk returns a tuple with the IpxeTemplateId field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemCreateRequest) GetIpxeTemplateIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.IpxeTemplateId.Get(), o.IpxeTemplateId.IsSet() +} + +// HasIpxeTemplateId returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasIpxeTemplateId() bool { + if o != nil && o.IpxeTemplateId.IsSet() { + return true + } + + return false +} + +// SetIpxeTemplateId gets a reference to the given NullableString and assigns it to the IpxeTemplateId field. +func (o *OperatingSystemCreateRequest) SetIpxeTemplateId(v string) { + o.IpxeTemplateId.Set(&v) +} + +// SetIpxeTemplateIdNil sets the value for IpxeTemplateId to be an explicit nil +func (o *OperatingSystemCreateRequest) SetIpxeTemplateIdNil() { + o.IpxeTemplateId.Set(nil) +} + +// UnsetIpxeTemplateId ensures that no value is present for IpxeTemplateId, not even an explicit nil +func (o *OperatingSystemCreateRequest) UnsetIpxeTemplateId() { + o.IpxeTemplateId.Unset() +} + +// GetIpxeTemplateParameters returns the IpxeTemplateParameters field value if set, zero value otherwise. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateParameters() []OperatingSystemIpxeParameter { + if o == nil || IsNil(o.IpxeTemplateParameters) { + var ret []OperatingSystemIpxeParameter + return ret + } + return o.IpxeTemplateParameters +} + +// GetIpxeTemplateParametersOk returns a tuple with the IpxeTemplateParameters field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateParametersOk() ([]OperatingSystemIpxeParameter, bool) { + if o == nil || IsNil(o.IpxeTemplateParameters) { + return nil, false + } + return o.IpxeTemplateParameters, true +} + +// HasIpxeTemplateParameters returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasIpxeTemplateParameters() bool { + if o != nil && !IsNil(o.IpxeTemplateParameters) { + return true + } + + return false +} + +// SetIpxeTemplateParameters gets a reference to the given []OperatingSystemIpxeParameter and assigns it to the IpxeTemplateParameters field. +func (o *OperatingSystemCreateRequest) SetIpxeTemplateParameters(v []OperatingSystemIpxeParameter) { + o.IpxeTemplateParameters = v +} + +// GetIpxeTemplateArtifacts returns the IpxeTemplateArtifacts field value if set, zero value otherwise. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateArtifacts() []OperatingSystemIpxeArtifact { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + var ret []OperatingSystemIpxeArtifact + return ret + } + return o.IpxeTemplateArtifacts +} + +// GetIpxeTemplateArtifactsOk returns a tuple with the IpxeTemplateArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemCreateRequest) GetIpxeTemplateArtifactsOk() ([]OperatingSystemIpxeArtifact, bool) { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + return nil, false + } + return o.IpxeTemplateArtifacts, true +} + +// HasIpxeTemplateArtifacts returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasIpxeTemplateArtifacts() bool { + if o != nil && !IsNil(o.IpxeTemplateArtifacts) { + return true + } + + return false +} + +// SetIpxeTemplateArtifacts gets a reference to the given []OperatingSystemIpxeArtifact and assigns it to the IpxeTemplateArtifacts field. +func (o *OperatingSystemCreateRequest) SetIpxeTemplateArtifacts(v []OperatingSystemIpxeArtifact) { + o.IpxeTemplateArtifacts = v +} + +// GetScope returns the Scope field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemCreateRequest) GetScope() string { + if o == nil || IsNil(o.Scope.Get()) { + var ret string + return ret + } + return *o.Scope.Get() +} + +// GetScopeOk returns a tuple with the Scope field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemCreateRequest) GetScopeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Scope.Get(), o.Scope.IsSet() +} + +// HasScope returns a boolean if a field has been set. +func (o *OperatingSystemCreateRequest) HasScope() bool { + if o != nil && o.Scope.IsSet() { + return true + } + + return false +} + +// SetScope gets a reference to the given NullableString and assigns it to the Scope field. +func (o *OperatingSystemCreateRequest) SetScope(v string) { + o.Scope.Set(&v) +} + +// SetScopeNil sets the value for Scope to be an explicit nil +func (o *OperatingSystemCreateRequest) SetScopeNil() { + o.Scope.Set(nil) +} + +// UnsetScope ensures that no value is present for Scope, not even an explicit nil +func (o *OperatingSystemCreateRequest) UnsetScope() { + o.Scope.Unset() +} + func (o OperatingSystemCreateRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -826,6 +984,18 @@ func (o OperatingSystemCreateRequest) ToMap() (map[string]interface{}, error) { if !IsNil(o.AllowOverride) { toSerialize["allowOverride"] = o.AllowOverride } + if o.IpxeTemplateId.IsSet() { + toSerialize["ipxeTemplateId"] = o.IpxeTemplateId.Get() + } + if !IsNil(o.IpxeTemplateParameters) { + toSerialize["ipxeTemplateParameters"] = o.IpxeTemplateParameters + } + if !IsNil(o.IpxeTemplateArtifacts) { + toSerialize["ipxeTemplateArtifacts"] = o.IpxeTemplateArtifacts + } + if o.Scope.IsSet() { + toSerialize["scope"] = o.Scope.Get() + } return toSerialize, nil } diff --git a/rest-api/sdk/standard/model_operating_system_ipxe_artifact.go b/rest-api/sdk/standard/model_operating_system_ipxe_artifact.go new file mode 100644 index 0000000000..66f3bba01f --- /dev/null +++ b/rest-api/sdk/standard/model_operating_system_ipxe_artifact.go @@ -0,0 +1,343 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the OperatingSystemIpxeArtifact type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OperatingSystemIpxeArtifact{} + +// OperatingSystemIpxeArtifact An artifact (kernel, initrd, ISO, ...) referenced by an iPXE OS definition +type OperatingSystemIpxeArtifact struct { + // Artifact name + Name *string `json:"name,omitempty"` + // Original URL for the artifact + Url *string `json:"url,omitempty"` + // Optional SHA256 checksum + Sha NullableString `json:"sha,omitempty"` + // Optional auth type: Basic or Bearer + AuthType NullableString `json:"authType,omitempty"` + // Optional auth token. Redacted in API responses. + AuthToken NullableString `json:"authToken,omitempty"` + // How to handle caching for this artifact + CacheStrategy *string `json:"cacheStrategy,omitempty"` +} + +// NewOperatingSystemIpxeArtifact instantiates a new OperatingSystemIpxeArtifact object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOperatingSystemIpxeArtifact() *OperatingSystemIpxeArtifact { + this := OperatingSystemIpxeArtifact{} + return &this +} + +// NewOperatingSystemIpxeArtifactWithDefaults instantiates a new OperatingSystemIpxeArtifact object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOperatingSystemIpxeArtifactWithDefaults() *OperatingSystemIpxeArtifact { + this := OperatingSystemIpxeArtifact{} + return &this +} + +// GetName returns the Name field value if set, zero value otherwise. +func (o *OperatingSystemIpxeArtifact) GetName() string { + if o == nil || IsNil(o.Name) { + var ret string + return ret + } + return *o.Name +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeArtifact) GetNameOk() (*string, bool) { + if o == nil || IsNil(o.Name) { + return nil, false + } + return o.Name, true +} + +// HasName returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasName() bool { + if o != nil && !IsNil(o.Name) { + return true + } + + return false +} + +// SetName gets a reference to the given string and assigns it to the Name field. +func (o *OperatingSystemIpxeArtifact) SetName(v string) { + o.Name = &v +} + +// GetUrl returns the Url field value if set, zero value otherwise. +func (o *OperatingSystemIpxeArtifact) GetUrl() string { + if o == nil || IsNil(o.Url) { + var ret string + return ret + } + return *o.Url +} + +// GetUrlOk returns a tuple with the Url field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeArtifact) GetUrlOk() (*string, bool) { + if o == nil || IsNil(o.Url) { + return nil, false + } + return o.Url, true +} + +// HasUrl returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasUrl() bool { + if o != nil && !IsNil(o.Url) { + return true + } + + return false +} + +// SetUrl gets a reference to the given string and assigns it to the Url field. +func (o *OperatingSystemIpxeArtifact) SetUrl(v string) { + o.Url = &v +} + +// GetSha returns the Sha field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemIpxeArtifact) GetSha() string { + if o == nil || IsNil(o.Sha.Get()) { + var ret string + return ret + } + return *o.Sha.Get() +} + +// GetShaOk returns a tuple with the Sha field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemIpxeArtifact) GetShaOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Sha.Get(), o.Sha.IsSet() +} + +// HasSha returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasSha() bool { + if o != nil && o.Sha.IsSet() { + return true + } + + return false +} + +// SetSha gets a reference to the given NullableString and assigns it to the Sha field. +func (o *OperatingSystemIpxeArtifact) SetSha(v string) { + o.Sha.Set(&v) +} + +// SetShaNil sets the value for Sha to be an explicit nil +func (o *OperatingSystemIpxeArtifact) SetShaNil() { + o.Sha.Set(nil) +} + +// UnsetSha ensures that no value is present for Sha, not even an explicit nil +func (o *OperatingSystemIpxeArtifact) UnsetSha() { + o.Sha.Unset() +} + +// GetAuthType returns the AuthType field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemIpxeArtifact) GetAuthType() string { + if o == nil || IsNil(o.AuthType.Get()) { + var ret string + return ret + } + return *o.AuthType.Get() +} + +// GetAuthTypeOk returns a tuple with the AuthType field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemIpxeArtifact) GetAuthTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.AuthType.Get(), o.AuthType.IsSet() +} + +// HasAuthType returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasAuthType() bool { + if o != nil && o.AuthType.IsSet() { + return true + } + + return false +} + +// SetAuthType gets a reference to the given NullableString and assigns it to the AuthType field. +func (o *OperatingSystemIpxeArtifact) SetAuthType(v string) { + o.AuthType.Set(&v) +} + +// SetAuthTypeNil sets the value for AuthType to be an explicit nil +func (o *OperatingSystemIpxeArtifact) SetAuthTypeNil() { + o.AuthType.Set(nil) +} + +// UnsetAuthType ensures that no value is present for AuthType, not even an explicit nil +func (o *OperatingSystemIpxeArtifact) UnsetAuthType() { + o.AuthType.Unset() +} + +// GetAuthToken returns the AuthToken field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemIpxeArtifact) GetAuthToken() string { + if o == nil || IsNil(o.AuthToken.Get()) { + var ret string + return ret + } + return *o.AuthToken.Get() +} + +// GetAuthTokenOk returns a tuple with the AuthToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemIpxeArtifact) GetAuthTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.AuthToken.Get(), o.AuthToken.IsSet() +} + +// HasAuthToken returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasAuthToken() bool { + if o != nil && o.AuthToken.IsSet() { + return true + } + + return false +} + +// SetAuthToken gets a reference to the given NullableString and assigns it to the AuthToken field. +func (o *OperatingSystemIpxeArtifact) SetAuthToken(v string) { + o.AuthToken.Set(&v) +} + +// SetAuthTokenNil sets the value for AuthToken to be an explicit nil +func (o *OperatingSystemIpxeArtifact) SetAuthTokenNil() { + o.AuthToken.Set(nil) +} + +// UnsetAuthToken ensures that no value is present for AuthToken, not even an explicit nil +func (o *OperatingSystemIpxeArtifact) UnsetAuthToken() { + o.AuthToken.Unset() +} + +// GetCacheStrategy returns the CacheStrategy field value if set, zero value otherwise. +func (o *OperatingSystemIpxeArtifact) GetCacheStrategy() string { + if o == nil || IsNil(o.CacheStrategy) { + var ret string + return ret + } + return *o.CacheStrategy +} + +// GetCacheStrategyOk returns a tuple with the CacheStrategy field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeArtifact) GetCacheStrategyOk() (*string, bool) { + if o == nil || IsNil(o.CacheStrategy) { + return nil, false + } + return o.CacheStrategy, true +} + +// HasCacheStrategy returns a boolean if a field has been set. +func (o *OperatingSystemIpxeArtifact) HasCacheStrategy() bool { + if o != nil && !IsNil(o.CacheStrategy) { + return true + } + + return false +} + +// SetCacheStrategy gets a reference to the given string and assigns it to the CacheStrategy field. +func (o *OperatingSystemIpxeArtifact) SetCacheStrategy(v string) { + o.CacheStrategy = &v +} + +func (o OperatingSystemIpxeArtifact) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OperatingSystemIpxeArtifact) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Name) { + toSerialize["name"] = o.Name + } + if !IsNil(o.Url) { + toSerialize["url"] = o.Url + } + if o.Sha.IsSet() { + toSerialize["sha"] = o.Sha.Get() + } + if o.AuthType.IsSet() { + toSerialize["authType"] = o.AuthType.Get() + } + if o.AuthToken.IsSet() { + toSerialize["authToken"] = o.AuthToken.Get() + } + if !IsNil(o.CacheStrategy) { + toSerialize["cacheStrategy"] = o.CacheStrategy + } + return toSerialize, nil +} + +type NullableOperatingSystemIpxeArtifact struct { + value *OperatingSystemIpxeArtifact + isSet bool +} + +func (v NullableOperatingSystemIpxeArtifact) Get() *OperatingSystemIpxeArtifact { + return v.value +} + +func (v *NullableOperatingSystemIpxeArtifact) Set(val *OperatingSystemIpxeArtifact) { + v.value = val + v.isSet = true +} + +func (v NullableOperatingSystemIpxeArtifact) IsSet() bool { + return v.isSet +} + +func (v *NullableOperatingSystemIpxeArtifact) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOperatingSystemIpxeArtifact(val *OperatingSystemIpxeArtifact) *NullableOperatingSystemIpxeArtifact { + return &NullableOperatingSystemIpxeArtifact{value: val, isSet: true} +} + +func (v NullableOperatingSystemIpxeArtifact) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOperatingSystemIpxeArtifact) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_operating_system_ipxe_parameter.go b/rest-api/sdk/standard/model_operating_system_ipxe_parameter.go new file mode 100644 index 0000000000..28940fb7ca --- /dev/null +++ b/rest-api/sdk/standard/model_operating_system_ipxe_parameter.go @@ -0,0 +1,162 @@ +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the OperatingSystemIpxeParameter type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OperatingSystemIpxeParameter{} + +// OperatingSystemIpxeParameter A name/value parameter passed to an iPXE template +type OperatingSystemIpxeParameter struct { + // Parameter name (used as a variable in the template) + Name *string `json:"name,omitempty"` + // Parameter value + Value *string `json:"value,omitempty"` +} + +// NewOperatingSystemIpxeParameter instantiates a new OperatingSystemIpxeParameter object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOperatingSystemIpxeParameter() *OperatingSystemIpxeParameter { + this := OperatingSystemIpxeParameter{} + return &this +} + +// NewOperatingSystemIpxeParameterWithDefaults instantiates a new OperatingSystemIpxeParameter object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOperatingSystemIpxeParameterWithDefaults() *OperatingSystemIpxeParameter { + this := OperatingSystemIpxeParameter{} + return &this +} + +// GetName returns the Name field value if set, zero value otherwise. +func (o *OperatingSystemIpxeParameter) GetName() string { + if o == nil || IsNil(o.Name) { + var ret string + return ret + } + return *o.Name +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeParameter) GetNameOk() (*string, bool) { + if o == nil || IsNil(o.Name) { + return nil, false + } + return o.Name, true +} + +// HasName returns a boolean if a field has been set. +func (o *OperatingSystemIpxeParameter) HasName() bool { + if o != nil && !IsNil(o.Name) { + return true + } + + return false +} + +// SetName gets a reference to the given string and assigns it to the Name field. +func (o *OperatingSystemIpxeParameter) SetName(v string) { + o.Name = &v +} + +// GetValue returns the Value field value if set, zero value otherwise. +func (o *OperatingSystemIpxeParameter) GetValue() string { + if o == nil || IsNil(o.Value) { + var ret string + return ret + } + return *o.Value +} + +// GetValueOk returns a tuple with the Value field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemIpxeParameter) GetValueOk() (*string, bool) { + if o == nil || IsNil(o.Value) { + return nil, false + } + return o.Value, true +} + +// HasValue returns a boolean if a field has been set. +func (o *OperatingSystemIpxeParameter) HasValue() bool { + if o != nil && !IsNil(o.Value) { + return true + } + + return false +} + +// SetValue gets a reference to the given string and assigns it to the Value field. +func (o *OperatingSystemIpxeParameter) SetValue(v string) { + o.Value = &v +} + +func (o OperatingSystemIpxeParameter) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OperatingSystemIpxeParameter) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Name) { + toSerialize["name"] = o.Name + } + if !IsNil(o.Value) { + toSerialize["value"] = o.Value + } + return toSerialize, nil +} + +type NullableOperatingSystemIpxeParameter struct { + value *OperatingSystemIpxeParameter + isSet bool +} + +func (v NullableOperatingSystemIpxeParameter) Get() *OperatingSystemIpxeParameter { + return v.value +} + +func (v *NullableOperatingSystemIpxeParameter) Set(val *OperatingSystemIpxeParameter) { + v.value = val + v.isSet = true +} + +func (v NullableOperatingSystemIpxeParameter) IsSet() bool { + return v.isSet +} + +func (v *NullableOperatingSystemIpxeParameter) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOperatingSystemIpxeParameter(val *OperatingSystemIpxeParameter) *NullableOperatingSystemIpxeParameter { + return &NullableOperatingSystemIpxeParameter{value: val, isSet: true} +} + +func (v NullableOperatingSystemIpxeParameter) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOperatingSystemIpxeParameter) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_operating_system_update_request.go b/rest-api/sdk/standard/model_operating_system_update_request.go index ee97e4e056..921e30a3ce 100644 --- a/rest-api/sdk/standard/model_operating_system_update_request.go +++ b/rest-api/sdk/standard/model_operating_system_update_request.go @@ -54,6 +54,14 @@ type OperatingSystemUpdateRequest struct { IsActive NullableBool `json:"isActive,omitempty"` // Optional deactivation note if OS is inactive DeactivationNote NullableString `json:"deactivationNote,omitempty"` + // ID of the iPXE template to use (Templated iPXE only). Mutually exclusive with ipxeScript and imageUrl. + IpxeTemplateId NullableString `json:"ipxeTemplateId,omitempty"` + // Parameters passed to the iPXE template (Templated iPXE only). + IpxeTemplateParameters []OperatingSystemIpxeParameter `json:"ipxeTemplateParameters,omitempty"` + // Artifacts (kernel, initrd, ISO, ...) for the iPXE OS definition (Templated iPXE only). + IpxeTemplateArtifacts []OperatingSystemIpxeArtifact `json:"ipxeTemplateArtifacts,omitempty"` + // Scope is immutable after creation; if provided the request is rejected. + Scope NullableString `json:"scope,omitempty"` } // NewOperatingSystemUpdateRequest instantiates a new OperatingSystemUpdateRequest object @@ -761,6 +769,156 @@ func (o *OperatingSystemUpdateRequest) UnsetDeactivationNote() { o.DeactivationNote.Unset() } +// GetIpxeTemplateId returns the IpxeTemplateId field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateId() string { + if o == nil || IsNil(o.IpxeTemplateId.Get()) { + var ret string + return ret + } + return *o.IpxeTemplateId.Get() +} + +// GetIpxeTemplateIdOk returns a tuple with the IpxeTemplateId field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.IpxeTemplateId.Get(), o.IpxeTemplateId.IsSet() +} + +// HasIpxeTemplateId returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasIpxeTemplateId() bool { + if o != nil && o.IpxeTemplateId.IsSet() { + return true + } + + return false +} + +// SetIpxeTemplateId gets a reference to the given NullableString and assigns it to the IpxeTemplateId field. +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateId(v string) { + o.IpxeTemplateId.Set(&v) +} + +// SetIpxeTemplateIdNil sets the value for IpxeTemplateId to be an explicit nil +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateIdNil() { + o.IpxeTemplateId.Set(nil) +} + +// UnsetIpxeTemplateId ensures that no value is present for IpxeTemplateId, not even an explicit nil +func (o *OperatingSystemUpdateRequest) UnsetIpxeTemplateId() { + o.IpxeTemplateId.Unset() +} + +// GetIpxeTemplateParameters returns the IpxeTemplateParameters field value if set, zero value otherwise. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateParameters() []OperatingSystemIpxeParameter { + if o == nil || IsNil(o.IpxeTemplateParameters) { + var ret []OperatingSystemIpxeParameter + return ret + } + return o.IpxeTemplateParameters +} + +// GetIpxeTemplateParametersOk returns a tuple with the IpxeTemplateParameters field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateParametersOk() ([]OperatingSystemIpxeParameter, bool) { + if o == nil || IsNil(o.IpxeTemplateParameters) { + return nil, false + } + return o.IpxeTemplateParameters, true +} + +// HasIpxeTemplateParameters returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasIpxeTemplateParameters() bool { + if o != nil && !IsNil(o.IpxeTemplateParameters) { + return true + } + + return false +} + +// SetIpxeTemplateParameters gets a reference to the given []OperatingSystemIpxeParameter and assigns it to the IpxeTemplateParameters field. +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateParameters(v []OperatingSystemIpxeParameter) { + o.IpxeTemplateParameters = v +} + +// GetIpxeTemplateArtifacts returns the IpxeTemplateArtifacts field value if set, zero value otherwise. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateArtifacts() []OperatingSystemIpxeArtifact { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + var ret []OperatingSystemIpxeArtifact + return ret + } + return o.IpxeTemplateArtifacts +} + +// GetIpxeTemplateArtifactsOk returns a tuple with the IpxeTemplateArtifacts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OperatingSystemUpdateRequest) GetIpxeTemplateArtifactsOk() ([]OperatingSystemIpxeArtifact, bool) { + if o == nil || IsNil(o.IpxeTemplateArtifacts) { + return nil, false + } + return o.IpxeTemplateArtifacts, true +} + +// HasIpxeTemplateArtifacts returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasIpxeTemplateArtifacts() bool { + if o != nil && !IsNil(o.IpxeTemplateArtifacts) { + return true + } + + return false +} + +// SetIpxeTemplateArtifacts gets a reference to the given []OperatingSystemIpxeArtifact and assigns it to the IpxeTemplateArtifacts field. +func (o *OperatingSystemUpdateRequest) SetIpxeTemplateArtifacts(v []OperatingSystemIpxeArtifact) { + o.IpxeTemplateArtifacts = v +} + +// GetScope returns the Scope field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OperatingSystemUpdateRequest) GetScope() string { + if o == nil || IsNil(o.Scope.Get()) { + var ret string + return ret + } + return *o.Scope.Get() +} + +// GetScopeOk returns a tuple with the Scope field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OperatingSystemUpdateRequest) GetScopeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Scope.Get(), o.Scope.IsSet() +} + +// HasScope returns a boolean if a field has been set. +func (o *OperatingSystemUpdateRequest) HasScope() bool { + if o != nil && o.Scope.IsSet() { + return true + } + + return false +} + +// SetScope gets a reference to the given NullableString and assigns it to the Scope field. +func (o *OperatingSystemUpdateRequest) SetScope(v string) { + o.Scope.Set(&v) +} + +// SetScopeNil sets the value for Scope to be an explicit nil +func (o *OperatingSystemUpdateRequest) SetScopeNil() { + o.Scope.Set(nil) +} + +// UnsetScope ensures that no value is present for Scope, not even an explicit nil +func (o *OperatingSystemUpdateRequest) UnsetScope() { + o.Scope.Unset() +} + func (o OperatingSystemUpdateRequest) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -819,6 +977,18 @@ func (o OperatingSystemUpdateRequest) ToMap() (map[string]interface{}, error) { if o.DeactivationNote.IsSet() { toSerialize["deactivationNote"] = o.DeactivationNote.Get() } + if o.IpxeTemplateId.IsSet() { + toSerialize["ipxeTemplateId"] = o.IpxeTemplateId.Get() + } + if !IsNil(o.IpxeTemplateParameters) { + toSerialize["ipxeTemplateParameters"] = o.IpxeTemplateParameters + } + if !IsNil(o.IpxeTemplateArtifacts) { + toSerialize["ipxeTemplateArtifacts"] = o.IpxeTemplateArtifacts + } + if o.Scope.IsSet() { + toSerialize["scope"] = o.Scope.Get() + } return toSerialize, nil }