diff --git a/rest-api/cli/README.md b/rest-api/cli/README.md index 7df502c480..d54a6f4568 100644 --- a/rest-api/cli/README.md +++ b/rest-api/cli/README.md @@ -209,6 +209,26 @@ Output formatting and pagination flags live on individual commands, not on the r Run `nicocli --help` for the full per-command flag list, including spec-derived query parameters and body fields. +### Bootstrap site prerequisites from a manifest + +`nicocli site bootstrap` creates or verifies the REST resources needed to use a Site. It initializes the calling organization, requires the Site to already exist, then processes its auto-created Site IP Blocks, Instance Types, Allocations, VPCs, VPC Prefixes, and optional Instances in dependency order. It never creates a Site. + +Start from the [example manifest](examples/site-prerequisites.yaml), replace the organization and resource values, and run: + +```bash +nicocli site bootstrap \ + --file cli/examples/site-prerequisites.yaml \ + --output-file site-prerequisites.resolved.yaml +``` + +The manifest uses `${...}` references so later requests can consume IDs returned by earlier requests. For example, `${site.id}` resolves to the existing Site ID, `${siteIpBlocks.id}` resolves to a Site IP Block selected from the fabric-prefix inventory, and `${allocations.network.allocationConstraints.0.derivedResourceId}` resolves to the Tenant IP Block created by the network Allocation. + +Site IP Blocks are not created by this command. NICo automatically creates them from fabric prefixes reported by the Site; the manifest's `siteIpBlocks` entries are read-only selectors for those existing resources. If the Site inventory has not arrived yet, bootstrap stops with a rerun message instead of posting a Provider-owned IP Block. + +For managed resources, the command looks up a recorded ID first, then uses exact name and scope. Matching resources are reused, while a matching resource whose returned configuration differs from the request stops the workflow with a drift error. The output file preserves the requests, selectors, and resolved resource IDs so it can be replayed after an interrupted run or against a replacement installation. All requests use operation paths and methods resolved from the same embedded OpenAPI model that builds the regular CLI commands. + +Bootstrap first calls the Service Account endpoint for `provider.org`. In Service Account mode that single call initializes and returns both identities, and the Provider and Tenant organization names must match; otherwise bootstrap falls back to the Provider and Tenant current endpoints. Separate Provider and Tenant organizations use the same token, so it must have the required role in both organizations. `provider.org` may be omitted when the Provider organization already comes from `--org`, `NICO_ORG`, or the selected CLI config. Site registration, fabric-prefix inventory, and machine readiness are external asynchronous steps; if a dependency is not ready yet, complete that step and rerun the same manifest. + ## Authentication ```bash diff --git a/rest-api/cli/examples/site-prerequisites.yaml b/rest-api/cli/examples/site-prerequisites.yaml new file mode 100644 index 0000000000..e9ca329dc0 --- /dev/null +++ b/rest-api/cli/examples/site-prerequisites.yaml @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Example input for `nicocli site bootstrap`. Each `request` object is passed +# to the corresponding REST operation and must contain only attributes allowed +# by that operation's OpenAPI request schema. + +provider: + org: provider-org +tenant: + org: tenant-org + +# The Site must already exist. Bootstrap looks it up but never creates it. +site: + request: + name: sjc4 + description: San Jose site 4 + +siteIpBlocks: + # Site IP Blocks are created automatically from fabric prefixes reported + # by the Site. This selector discovers the existing block; it never posts + # a Provider-created IP Block. Rerun bootstrap if inventory is not ready. + match: + siteId: "${site.id}" + routingType: DatacenterOnly + prefix: 10.40.0.0 + prefixLength: 16 + +instanceTypes: + compute: + request: + name: compute-large + description: Compute profile for SJC4 + siteId: "${site.id}" + labels: + tier: compute + # Replace this with a capability reported by machines at the site. + machineCapabilities: + - type: CPU + name: x86-64 + count: 1 + +allocations: + network: + request: + name: tenant-network + description: Tenant network allocation for SJC4 + tenantId: "${tenant.id}" + siteId: "${site.id}" + allocationConstraints: + - resourceType: IPBlock + resourceTypeId: "${siteIpBlocks.id}" + constraintType: OnDemand + constraintValue: 24 + compute: + request: + name: tenant-compute + description: One reserved machine for the tenant + tenantId: "${tenant.id}" + siteId: "${site.id}" + allocationConstraints: + - resourceType: InstanceType + resourceTypeId: "${instanceTypes.compute.id}" + constraintType: Reserved + constraintValue: 1 + +vpcs: + tenant: + request: + name: tenant-vpc + description: Tenant VPC at SJC4 + siteId: "${site.id}" + +vpcPrefixes: + tenant: + request: + name: tenant-prefix + vpcId: "${vpcs.tenant.id}" + ipBlockId: "${allocations.network.allocationConstraints.0.derivedResourceId}" + prefixLength: 24 + +# Instances are optional. Remove this section until machines assigned to the +# instance type are ready and the compute allocation can be satisfied. +instances: + worker: + request: + name: worker-1 + description: First tenant worker + tenantId: "${tenant.id}" + instanceTypeId: "${instanceTypes.compute.id}" + vpcId: "${vpcs.tenant.id}" + interfaces: + - vpcPrefixId: "${vpcPrefixes.tenant.id}" + isPhysical: true diff --git a/rest-api/cli/pkg/app.go b/rest-api/cli/pkg/app.go index 9701833efd..1a8bcac115 100644 --- a/rest-api/cli/pkg/app.go +++ b/rest-api/cli/pkg/app.go @@ -31,6 +31,7 @@ func NewApp(specData []byte) (*cli.App, error) { } commands := BuildCommands(spec) + commands = addSiteBootstrapCommand(commands, spec) commands = append(commands, LoginCommand()) commands = append(commands, InitCommand()) commands = append(commands, completionCommand()) diff --git a/rest-api/cli/pkg/commands.go b/rest-api/cli/pkg/commands.go index 3ec4fc8836..071906682b 100644 --- a/rest-api/cli/pkg/commands.go +++ b/rest-api/cli/pkg/commands.go @@ -5,6 +5,7 @@ package cli import ( "encoding/json" + "errors" "fmt" "net/http" "os" @@ -16,6 +17,11 @@ import ( cli "github.com/urfave/cli/v2" ) +var ( + errOpenAPIOperationUnavailable = errors.New("OpenAPI operation is unavailable") + errOpenAPIResourceIDMissing = errors.New("OpenAPI operation has no resource ID path parameter") +) + type resolvedOp struct { tag string action string @@ -25,6 +31,42 @@ type resolvedOp struct { pathParams []Parameter } +type operationIndex map[string]resolvedOp + +func newOperationIndex(spec *Spec) operationIndex { + index := make(operationIndex) + for _, operation := range collectOperations(spec) { + index[operation.op.OperationID] = operation + } + return index +} + +func (index operationIndex) require(operationID string) (resolvedOp, error) { + operation, ok := index[operationID] + if !ok { + return resolvedOp{}, fmt.Errorf("%w: %q", errOpenAPIOperationUnavailable, operationID) + } + return operation, nil +} + +func (operation resolvedOp) parameters() []Parameter { + parameters := append([]Parameter{}, operation.pathParams...) + return append(parameters, operation.op.Parameters...) +} + +func (operation resolvedOp) resourceIDParameter() (string, error) { + for _, parameter := range operation.parameters() { + if parameter.In == "path" && parameter.Name != "org" { + return parameter.Name, nil + } + } + return "", fmt.Errorf("%w: %q", errOpenAPIResourceIDMissing, operation.op.OperationID) +} + +func (operation resolvedOp) execute(client *Client, pathParams, queryParams map[string]string, body []byte) ([]byte, http.Header, error) { + return client.Do(operation.method, operation.path, pathParams, queryParams, body) +} + // bodyField tracks a request body property for type-aware flag reading. type bodyField struct { jsonName string @@ -298,8 +340,7 @@ func buildActionCommand(spec *Spec, ro resolvedOp, subResource string) *cli.Comm var argParams []string - allParams := append([]Parameter{}, ro.pathParams...) - allParams = append(allParams, ro.op.Parameters...) + allParams := ro.parameters() for _, p := range allParams { if p.Name == "org" { @@ -426,7 +467,7 @@ func buildActionCommand(spec *Spec, ro resolvedOp, subResource string) *cli.Comm return fetchAllPages(client, ro.method, ro.path, pathParams, queryParams, c.String("output")) } - respBody, respHeaders, err := client.Do(ro.method, ro.path, pathParams, queryParams, body) + respBody, respHeaders, err := ro.execute(client, pathParams, queryParams, body) if err != nil { return err } diff --git a/rest-api/cli/pkg/site_bootstrap.go b/rest-api/cli/pkg/site_bootstrap.go new file mode 100644 index 0000000000..aba15927b7 --- /dev/null +++ b/rest-api/cli/pkg/site_bootstrap.go @@ -0,0 +1,1111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "os" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + + urfavecli "github.com/urfave/cli/v2" + "gopkg.in/yaml.v3" +) + +const ( + bootstrapPageSize = 100 + bootstrapMaxPages = 1000 +) + +var ( + bootstrapAliasPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) + bootstrapRefPattern = regexp.MustCompile(`\$\{([^{}]+)\}`) + + errInvalidBootstrapManifest = errors.New("invalid site prerequisite manifest") + errInvalidBootstrapResource = errors.New("invalid site prerequisite resource") + errBootstrapReference = errors.New("invalid site prerequisite reference") + errBootstrapResponse = errors.New("invalid site prerequisite API response") + errBootstrapDrift = errors.New("site prerequisite resource drift") + errBootstrapClientRequired = errors.New("REST client is required") + errBootstrapManifestRequired = errors.New("manifest is required") + errBootstrapSpecRequired = errors.New("OpenAPI spec is required") +) + +// sitePrerequisiteManifest is a declarative, replayable site bring-up plan. +// Managed resource requests are passed through to their existing REST +// operations after ${...} references have been resolved. Site IP Blocks are +// selected read-only after the Site reports its fabric-prefix inventory. +type sitePrerequisiteManifest struct { + Provider bootstrapOrganization `yaml:"provider"` + Tenant bootstrapOrganization `yaml:"tenant"` + Site *bootstrapResource `yaml:"site"` + SiteIPBlocks *bootstrapExistingResource `yaml:"siteIpBlocks,omitempty"` + InstanceTypes map[string]*bootstrapResource `yaml:"instanceTypes,omitempty"` + Allocations map[string]*bootstrapResource `yaml:"allocations,omitempty"` + VPCs map[string]*bootstrapResource `yaml:"vpcs,omitempty"` + VPCPrefixes map[string]*bootstrapResource `yaml:"vpcPrefixes,omitempty"` + Instances map[string]*bootstrapResource `yaml:"instances,omitempty"` +} + +type bootstrapOrganization struct { + Org string `yaml:"org"` + ID string `yaml:"id,omitempty"` +} + +type bootstrapResource struct { + ID string `yaml:"id,omitempty"` + Request map[string]any `yaml:"request"` +} + +type bootstrapExistingResource struct { + ID string `yaml:"id,omitempty"` + Match map[string]any `yaml:"match,omitempty"` +} + +type bootstrapResourceAPI struct { + category string + displayName string + providerScoped bool + list resolvedOp + create resolvedOp + get resolvedOp + itemIDParam string +} + +type bootstrapResourceGroup struct { + api bootstrapResourceAPI + resources map[string]*bootstrapResource +} + +type siteBootstrapOperations struct { + serviceAccount resolvedOp + provider resolvedOp + tenant resolvedOp + site bootstrapResourceAPI + siteIPBlock bootstrapResourceAPI + instanceType bootstrapResourceAPI + allocation bootstrapResourceAPI + vpc bootstrapResourceAPI + vpcPrefix bootstrapResourceAPI + instance bootstrapResourceAPI +} + +type siteBootstrap struct { + client *Client + manifest *sitePrerequisiteManifest + progress io.Writer + operations *siteBootstrapOperations + references bootstrapReferences +} + +type bootstrapReferences map[string]any + +type bootstrapServiceAccount struct { + Enabled bool `json:"enabled"` + InfrastructureProviderID *string `json:"infrastructureProviderId"` + TenantID *string `json:"tenantId"` +} + +func addSiteBootstrapCommand(commands []*urfavecli.Command, spec *Spec) []*urfavecli.Command { + for _, command := range commands { + if command.Name != "site" { + continue + } + command.Subcommands = append(command.Subcommands, siteBootstrapCommand(spec)) + sort.Slice(command.Subcommands, func(i, j int) bool { + return command.Subcommands[i].Name < command.Subcommands[j].Name + }) + return commands + } + + return append(commands, &urfavecli.Command{ + Name: "site", + Usage: "site operations", + Subcommands: []*urfavecli.Command{siteBootstrapCommand(spec)}, + }) +} + +func siteBootstrapCommand(spec *Spec) *urfavecli.Command { + return &urfavecli.Command{ + Name: "bootstrap", + Usage: "Create or verify site prerequisite resources from a manifest", + UsageText: binaryName + " site bootstrap --file [--output-file ]", + Flags: []urfavecli.Flag{ + &urfavecli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Input YAML manifest path (use - for stdin)", + Required: true, + }, + &urfavecli.StringFlag{ + Name: "output-file", + Usage: "Write the replayable manifest with resolved resource IDs to this path (use - for stdout)", + Value: "-", + }, + }, + Action: func(c *urfavecli.Context) error { + stdin := io.Reader(os.Stdin) + stdout := io.Writer(os.Stdout) + stderr := io.Writer(os.Stderr) + if c.App.Reader != nil { + stdin = c.App.Reader + } + if c.App.Writer != nil { + stdout = c.App.Writer + } + if c.App.ErrWriter != nil { + stderr = c.App.ErrWriter + } + + manifest, err := readSitePrerequisiteManifest(c.String("file"), stdin) + if err != nil { + return err + } + + if manifest.Provider.Org != "" && c.String("org") == "" { + if err := c.Set("org", manifest.Provider.Org); err != nil { + return fmt.Errorf("using provider.org as the CLI organization: %w", err) + } + } + + client, err := clientFromContext(c) + if err != nil { + return err + } + if manifest.Provider.Org == "" { + manifest.Provider.Org = client.Org + } + + bootstrap, err := newSiteBootstrap(spec, client, manifest, stderr) + if err != nil { + return fmt.Errorf("preparing site prerequisite bootstrap: %w", err) + } + if err := bootstrap.apply(); err != nil { + return fmt.Errorf("bootstrapping site prerequisites: %w", err) + } + + if err := writeSitePrerequisiteManifest(c.String("output-file"), stdout, manifest); err != nil { + return err + } + return nil + }, + } +} + +func newSiteBootstrap(spec *Spec, client *Client, manifest *sitePrerequisiteManifest, progress io.Writer) (*siteBootstrap, error) { + operations, err := newSiteBootstrapOperations(spec) + if err != nil { + return nil, err + } + if progress == nil { + progress = io.Discard + } + return &siteBootstrap{ + client: client, + manifest: manifest, + progress: progress, + operations: operations, + references: bootstrapReferences{}, + }, nil +} + +func newSiteBootstrapOperations(spec *Spec) (*siteBootstrapOperations, error) { + if spec == nil { + return nil, errBootstrapSpecRequired + } + index := newOperationIndex(spec) + + serviceAccount, err := index.require("get-current-service-account") + if err != nil { + return nil, err + } + provider, err := index.require("get-current-infrastructure-provider") + if err != nil { + return nil, err + } + tenant, err := index.require("get-current-tenant") + if err != nil { + return nil, err + } + + operations := new(siteBootstrapOperations) + operations.serviceAccount = serviceAccount + operations.provider = provider + operations.tenant = tenant + resources := []struct { + target *bootstrapResourceAPI + category string + displayName string + listID string + createID string + getID string + providerScoped bool + }{ + {target: &operations.site, category: "site", displayName: "site", listID: "get-all-site", createID: "", getID: "get-site", providerScoped: true}, + {target: &operations.siteIPBlock, category: "siteIpBlocks", displayName: "site IP block", listID: "get-all-ipblock", createID: "", getID: "get-ipblock", providerScoped: true}, + {target: &operations.instanceType, category: "instanceTypes", displayName: "instance type", listID: "get-all-instance-type", createID: "create-instance-type", getID: "get-instance-type", providerScoped: true}, + {target: &operations.allocation, category: "allocations", displayName: "allocation", listID: "get-all-allocation", createID: "create-allocation", getID: "get-allocation", providerScoped: true}, + {target: &operations.vpc, category: "vpcs", displayName: "VPC", listID: "get-all-vpc", createID: "create-vpc", getID: "get-vpc", providerScoped: false}, + {target: &operations.vpcPrefix, category: "vpcPrefixes", displayName: "VPC prefix", listID: "get-all-vpc-prefix", createID: "create-vpc-prefix", getID: "get-vpc-prefix", providerScoped: false}, + {target: &operations.instance, category: "instances", displayName: "instance", listID: "get-all-instance", createID: "create-instance", getID: "get-instance", providerScoped: false}, + } + for _, resource := range resources { + *resource.target, err = index.bootstrapResourceAPI( + resource.category, + resource.displayName, + resource.listID, + resource.createID, + resource.getID, + resource.providerScoped, + ) + if err != nil { + return nil, err + } + } + return operations, nil +} + +func (index operationIndex) bootstrapResourceAPI(category, displayName, listID, createID, getID string, providerScoped bool) (bootstrapResourceAPI, error) { + list, err := index.require(listID) + if err != nil { + return bootstrapResourceAPI{}, err + } + get, err := index.require(getID) + if err != nil { + return bootstrapResourceAPI{}, err + } + itemIDParam, err := get.resourceIDParameter() + if err != nil { + return bootstrapResourceAPI{}, err + } + var create resolvedOp + if createID != "" { + create, err = index.require(createID) + if err != nil { + return bootstrapResourceAPI{}, err + } + } + return bootstrapResourceAPI{ + category: category, + displayName: displayName, + providerScoped: providerScoped, + list: list, + create: create, + get: get, + itemIDParam: itemIDParam, + }, nil +} + +func readSitePrerequisiteManifest(filename string, stdin io.Reader) (*sitePrerequisiteManifest, error) { + var data []byte + var err error + if filename == "-" { + data, err = io.ReadAll(stdin) + } else { + data, err = os.ReadFile(filename) + } + if err != nil { + return nil, fmt.Errorf("reading site prerequisite manifest: %w", err) + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + manifest := &sitePrerequisiteManifest{} + if err := decoder.Decode(manifest); err != nil { + return nil, fmt.Errorf("parsing site prerequisite manifest: %w", err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return nil, fmt.Errorf("parsing site prerequisite manifest: %w: multiple YAML documents are not supported", errInvalidBootstrapManifest) + } + return nil, fmt.Errorf("parsing site prerequisite manifest: %w", err) + } + if err := manifest.validate(); err != nil { + return nil, fmt.Errorf("validating site prerequisite manifest: %w", err) + } + return manifest, nil +} + +func writeSitePrerequisiteManifest(filename string, stdout io.Writer, manifest *sitePrerequisiteManifest) error { + data, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("encoding resolved site prerequisite manifest: %w", err) + } + if filename == "-" { + if _, err := stdout.Write(data); err != nil { + return fmt.Errorf("writing resolved site prerequisite manifest: %w", err) + } + return nil + } + if err := os.WriteFile(filename, data, 0o600); err != nil { + return fmt.Errorf("writing resolved site prerequisite manifest: %w", err) + } + return nil +} + +func (manifest *sitePrerequisiteManifest) validate() error { + if manifest.Tenant.Org == "" { + return fmt.Errorf("%w: tenant.org is required", errInvalidBootstrapManifest) + } + if manifest.Site == nil { + return fmt.Errorf("%w: site is required", errInvalidBootstrapManifest) + } + if err := manifest.Site.validate("site"); err != nil { + return err + } + if manifest.SiteIPBlocks != nil { + if err := manifest.SiteIPBlocks.validate("siteIpBlocks"); err != nil { + return err + } + } + + groups := []struct { + name string + resources map[string]*bootstrapResource + }{ + {name: "instanceTypes", resources: manifest.InstanceTypes}, + {name: "allocations", resources: manifest.Allocations}, + {name: "vpcs", resources: manifest.VPCs}, + {name: "vpcPrefixes", resources: manifest.VPCPrefixes}, + {name: "instances", resources: manifest.Instances}, + } + for _, group := range groups { + for alias, resource := range group.resources { + if !bootstrapAliasPattern.MatchString(alias) { + return fmt.Errorf("%w: %s alias %q must start with a letter and contain only letters, digits, underscores, or hyphens", errInvalidBootstrapManifest, group.name, alias) + } + if err := resource.validate(group.name + "." + alias); err != nil { + return err + } + } + } + return nil +} + +func (resource *bootstrapResource) validate(path string) error { + if resource == nil { + return fmt.Errorf("%w: %s must not be null", errInvalidBootstrapResource, path) + } + if len(resource.Request) == 0 { + return fmt.Errorf("%w: %s.request is required", errInvalidBootstrapResource, path) + } + name, ok := resource.Request["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return fmt.Errorf("%w: %s.request.name is required", errInvalidBootstrapResource, path) + } + return nil +} + +func (resource *bootstrapExistingResource) validate(path string) error { + if resource == nil { + return fmt.Errorf("%w: %s must not be null", errInvalidBootstrapResource, path) + } + if resource.ID == "" && len(resource.Match) == 0 { + return fmt.Errorf("%w: %s.id or %s.match is required", errInvalidBootstrapResource, path, path) + } + return nil +} + +func (bootstrap *siteBootstrap) apply() error { + if bootstrap.client == nil { + return errBootstrapClientRequired + } + if bootstrap.manifest == nil { + return errBootstrapManifestRequired + } + if bootstrap.manifest.Provider.Org == "" { + return fmt.Errorf("%w: provider.org is required when applying a manifest", errInvalidBootstrapManifest) + } + + originalOrg := bootstrap.client.Org + defer func() { + bootstrap.client.Org = originalOrg + }() + + if err := bootstrap.initializeOrganizations(); err != nil { + return err + } + + site, err := bootstrap.ensureResource(bootstrap.operations.site, "site", bootstrap.manifest.Site) + if err != nil { + return err + } + bootstrap.references["site"] = site + + if bootstrap.manifest.SiteIPBlocks != nil { + siteIPBlock, err := bootstrap.discoverExistingResource(bootstrap.operations.siteIPBlock, "siteIpBlocks", bootstrap.manifest.SiteIPBlocks) + if err != nil { + return err + } + bootstrap.references["siteIpBlocks"] = siteIPBlock + } + for _, group := range bootstrap.operations.managedGroups(bootstrap.manifest) { + if err := bootstrap.ensureResources(group.api, group.resources); err != nil { + return err + } + } + return nil +} + +func (bootstrap *siteBootstrap) initializeOrganizations() error { + manifest := bootstrap.manifest + initialized, err := bootstrap.initializeServiceAccount(manifest.Provider.Org) + if err != nil { + return err + } + if initialized { + return nil + } + + provider, err := bootstrap.resolveCurrentOrganization(bootstrap.operations.provider, manifest.Provider.Org, "provider") + if err != nil { + return err + } + manifest.Provider.ID, err = bootstrapResponseID(provider) + if err != nil { + return fmt.Errorf("resolving provider: %w", err) + } + bootstrap.references["provider"] = provider + + tenant, err := bootstrap.resolveCurrentOrganization(bootstrap.operations.tenant, manifest.Tenant.Org, "tenant") + if err != nil { + return err + } + manifest.Tenant.ID, err = bootstrapResponseID(tenant) + if err != nil { + return fmt.Errorf("resolving tenant: %w", err) + } + bootstrap.references["tenant"] = tenant + return nil +} + +func (bootstrap *siteBootstrap) initializeServiceAccount(org string) (bool, error) { + bootstrap.client.Org = org + body, _, err := bootstrap.operations.serviceAccount.execute(bootstrap.client, nil, nil, nil) + if err != nil { + return false, fmt.Errorf("resolving service account for org %q: %w", org, err) + } + var status bootstrapServiceAccount + if err := json.Unmarshal(body, &status); err != nil { + return false, fmt.Errorf("decoding service account for org %q: %w", org, err) + } + if !status.Enabled { + fmt.Fprintf(bootstrap.progress, "service account mode is not enabled for %s; resolving provider and tenant separately\n", org) + return false, nil + } + if bootstrap.manifest.Provider.Org != bootstrap.manifest.Tenant.Org { + return false, fmt.Errorf("%w: service account mode requires provider.org and tenant.org to match", errInvalidBootstrapManifest) + } + if status.InfrastructureProviderID == nil || *status.InfrastructureProviderID == "" || status.TenantID == nil || *status.TenantID == "" { + return false, fmt.Errorf("%w: enabled service account response for org %q is missing provider or tenant ID", errBootstrapResponse, org) + } + + provider := map[string]any{"id": *status.InfrastructureProviderID, "org": org} + tenant := map[string]any{"id": *status.TenantID, "org": org} + bootstrap.manifest.Provider.ID = *status.InfrastructureProviderID + bootstrap.manifest.Tenant.ID = *status.TenantID + bootstrap.references["serviceAccount"] = map[string]any{ + "enabled": true, + "infrastructureProviderId": *status.InfrastructureProviderID, + "tenantId": *status.TenantID, + } + bootstrap.references["provider"] = provider + bootstrap.references["tenant"] = tenant + fmt.Fprintf(bootstrap.progress, "resolved service account %s (provider %s, tenant %s)\n", org, *status.InfrastructureProviderID, *status.TenantID) + return true, nil +} + +func (bootstrap *siteBootstrap) resolveCurrentOrganization(operation resolvedOp, org, displayName string) (map[string]any, error) { + bootstrap.client.Org = org + body, _, err := operation.execute(bootstrap.client, nil, nil, nil) + if err != nil { + return nil, fmt.Errorf("resolving %s for org %q: %w", displayName, org, err) + } + response, err := decodeBootstrapObject(body) + if err != nil { + return nil, fmt.Errorf("decoding %s for org %q: %w", displayName, org, err) + } + id, err := bootstrapResponseID(response) + if err != nil { + return nil, fmt.Errorf("resolving %s for org %q: %w", displayName, org, err) + } + fmt.Fprintf(bootstrap.progress, "resolved %s %s (%s)\n", displayName, org, id) + return response, nil +} + +func (operations *siteBootstrapOperations) managedGroups(manifest *sitePrerequisiteManifest) []bootstrapResourceGroup { + return []bootstrapResourceGroup{ + {api: operations.instanceType, resources: manifest.InstanceTypes}, + {api: operations.allocation, resources: manifest.Allocations}, + {api: operations.vpc, resources: manifest.VPCs}, + {api: operations.vpcPrefix, resources: manifest.VPCPrefixes}, + {api: operations.instance, resources: manifest.Instances}, + } +} + +func (bootstrap *siteBootstrap) ensureResources(api bootstrapResourceAPI, resources map[string]*bootstrapResource) error { + resolved := map[string]any{} + bootstrap.references[api.category] = resolved + for _, alias := range sortedBootstrapAliases(resources) { + response, err := bootstrap.ensureResource(api, alias, resources[alias]) + if err != nil { + return err + } + resolved[alias] = response + } + return nil +} + +func (bootstrap *siteBootstrap) ensureResource(api bootstrapResourceAPI, alias string, resource *bootstrapResource) (map[string]any, error) { + resolvedValue, err := bootstrap.references.resolve(resource.Request) + if err != nil { + return nil, fmt.Errorf("resolving %s %q request: %w", api.displayName, alias, err) + } + request, ok := resolvedValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("resolving %s %q request: %w: expected an object", api.displayName, alias, errInvalidBootstrapResource) + } + if err := (&bootstrapResource{Request: request}).validate(api.manifestPath(alias)); err != nil { + return nil, fmt.Errorf("resolving %s %q request: %w", api.displayName, alias, err) + } + name, _ := request["name"].(string) + + bootstrap.client.Org = api.organization(bootstrap.manifest) + + candidateID := resource.ID + if candidateID == "" { + candidateID, _ = request["id"].(string) + } + if candidateID != "" { + response, err := api.getResource(bootstrap.client, candidateID) + if err == nil { + if err := api.verify(alias, request, response); err != nil { + return nil, err + } + resource.ID = candidateID + fmt.Fprintf(bootstrap.progress, "reused %s %s (%s)\n", api.displayName, name, candidateID) + return response, nil + } + if !isBootstrapNotFound(err) { + return nil, fmt.Errorf("retrieving %s %q by ID %q: %w", api.displayName, alias, candidateID, err) + } + } + + response, found, err := api.findByName(bootstrap.client, request) + if err != nil { + return nil, fmt.Errorf("finding %s %q: %w", api.displayName, alias, err) + } + if found { + if err := api.verify(alias, request, response); err != nil { + return nil, err + } + resource.ID, err = bootstrapResponseID(response) + if err != nil { + return nil, fmt.Errorf("finding %s %q: %w", api.displayName, alias, err) + } + fmt.Fprintf(bootstrap.progress, "reused %s %s (%s)\n", api.displayName, name, resource.ID) + return response, nil + } + if api.create.op == nil { + return nil, fmt.Errorf("%w: required %s %q was not found", errInvalidBootstrapResource, api.displayName, name) + } + + requestBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("encoding %s %q request: %w", api.displayName, alias, err) + } + body, _, err := api.create.execute(bootstrap.client, nil, nil, requestBody) + if err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { + response, found, findErr := api.findByName(bootstrap.client, request) + if findErr == nil && found { + if verifyErr := api.verify(alias, request, response); verifyErr != nil { + return nil, verifyErr + } + resource.ID, findErr = bootstrapResponseID(response) + if findErr == nil { + fmt.Fprintf(bootstrap.progress, "reused %s %s (%s) after a concurrent create\n", api.displayName, name, resource.ID) + return response, nil + } + } + } + return nil, fmt.Errorf("creating %s %q: %w", api.displayName, alias, err) + } + response, err = decodeBootstrapObject(body) + if err != nil { + return nil, fmt.Errorf("decoding created %s %q: %w", api.displayName, alias, err) + } + resource.ID, err = bootstrapResponseID(response) + if err != nil { + return nil, fmt.Errorf("decoding created %s %q: %w", api.displayName, alias, err) + } + fmt.Fprintf(bootstrap.progress, "created %s %s (%s)\n", api.displayName, name, resource.ID) + return response, nil +} + +func (bootstrap *siteBootstrap) discoverExistingResource(api bootstrapResourceAPI, alias string, resource *bootstrapExistingResource) (map[string]any, error) { + bootstrap.client.Org = api.organization(bootstrap.manifest) + + if resource.ID != "" { + response, err := api.getResource(bootstrap.client, resource.ID) + if err == nil { + if len(resource.Match) > 0 { + match, matchErr := bootstrap.resolveExistingResourceMatch(api, alias, resource.Match) + if matchErr != nil { + return nil, matchErr + } + if !bootstrapValueMatches(match, response) { + return nil, fmt.Errorf("%w: existing %s %q does not match the manifest selector", errBootstrapDrift, api.displayName, alias) + } + } + fmt.Fprintf(bootstrap.progress, "resolved %s %s (%s)\n", api.displayName, alias, resource.ID) + return response, nil + } + if !isBootstrapNotFound(err) || len(resource.Match) == 0 { + return nil, fmt.Errorf("retrieving %s %q by ID %q: %w", api.displayName, alias, resource.ID, err) + } + } + + match, err := bootstrap.resolveExistingResourceMatch(api, alias, resource.Match) + if err != nil { + return nil, err + } + response, found, err := api.findMatching(bootstrap.client, match) + if err != nil { + return nil, fmt.Errorf("finding %s %q: %w", api.displayName, alias, err) + } + if !found { + return nil, fmt.Errorf("%w: %s %q is not available yet; wait for Site fabric-prefix inventory and rerun", errInvalidBootstrapResource, api.displayName, alias) + } + resource.ID, err = bootstrapResponseID(response) + if err != nil { + return nil, fmt.Errorf("finding %s %q: %w", api.displayName, alias, err) + } + fmt.Fprintf(bootstrap.progress, "resolved %s %s (%s)\n", api.displayName, alias, resource.ID) + return response, nil +} + +func (bootstrap *siteBootstrap) resolveExistingResourceMatch(api bootstrapResourceAPI, alias string, selector map[string]any) (map[string]any, error) { + resolvedValue, err := bootstrap.references.resolve(selector) + if err != nil { + return nil, fmt.Errorf("resolving %s %q match: %w", api.displayName, alias, err) + } + match, ok := resolvedValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("resolving %s %q match: %w: expected an object", api.displayName, alias, errInvalidBootstrapResource) + } + return match, nil +} + +func (api bootstrapResourceAPI) organization(manifest *sitePrerequisiteManifest) string { + if api.providerScoped { + return manifest.Provider.Org + } + return manifest.Tenant.Org +} + +func (api bootstrapResourceAPI) manifestPath(alias string) string { + if alias == api.category { + return api.category + } + return api.category + "." + alias +} + +func (api bootstrapResourceAPI) getResource(client *Client, id string) (map[string]any, error) { + body, _, err := api.get.execute(client, map[string]string{api.itemIDParam: id}, nil, nil) + if err != nil { + return nil, err + } + return decodeBootstrapObject(body) +} + +func (api bootstrapResourceAPI) findByName(client *Client, request map[string]any) (map[string]any, bool, error) { + name, _ := request["name"].(string) + var matches []map[string]any + for page := 1; page <= bootstrapMaxPages; page++ { + query := map[string]string{ + "query": name, + "pageNumber": strconv.Itoa(page), + "pageSize": strconv.Itoa(bootstrapPageSize), + } + body, _, err := api.list.execute(client, nil, query, nil) + if err != nil { + return nil, false, err + } + items, err := decodeBootstrapList(body) + if err != nil { + return nil, false, err + } + for _, item := range items { + if itemName, _ := item["name"].(string); itemName == name && bootstrapIdentityMatches(request, item) { + matches = append(matches, item) + } + } + if len(items) < bootstrapPageSize { + break + } + } + if len(matches) == 0 { + return nil, false, nil + } + if len(matches) > 1 { + return nil, false, fmt.Errorf("%w: multiple resources named %q matched the manifest scope", errInvalidBootstrapResource, name) + } + return matches[0], true, nil +} + +func (api bootstrapResourceAPI) findMatching(client *Client, match map[string]any) (map[string]any, bool, error) { + query := api.queryFromMatch(match) + var matches []map[string]any + for page := 1; page <= bootstrapMaxPages; page++ { + query["pageNumber"] = strconv.Itoa(page) + query["pageSize"] = strconv.Itoa(bootstrapPageSize) + body, _, err := api.list.execute(client, nil, query, nil) + if err != nil { + return nil, false, err + } + items, err := decodeBootstrapList(body) + if err != nil { + return nil, false, err + } + for _, item := range items { + if bootstrapValueMatches(match, item) { + matches = append(matches, item) + } + } + if len(items) < bootstrapPageSize { + break + } + } + if len(matches) == 0 { + return nil, false, nil + } + if len(matches) > 1 { + return nil, false, fmt.Errorf("%w: multiple resources matched %s selector", errInvalidBootstrapResource, api.displayName) + } + return matches[0], true, nil +} + +func (api bootstrapResourceAPI) queryFromMatch(match map[string]any) map[string]string { + query := map[string]string{} + for _, parameter := range api.list.parameters() { + if parameter.In != "query" { + continue + } + value, ok := match[parameter.Name] + if !ok { + continue + } + switch value.(type) { + case string, bool, json.Number, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + query[parameter.Name] = fmt.Sprint(value) + } + } + return query +} + +func (api bootstrapResourceAPI) verify(alias string, request, actual map[string]any) error { + differences := bootstrapSubsetDifferences(request, actual, "") + if len(differences) == 0 { + return nil + } + return fmt.Errorf("%w: existing %s %q does not match the manifest request: %s", errBootstrapDrift, api.displayName, alias, strings.Join(differences, "; ")) +} + +func sortedBootstrapAliases[T any](resources map[string]T) []string { + aliases := make([]string, 0, len(resources)) + for alias := range resources { + aliases = append(aliases, alias) + } + sort.Strings(aliases) + return aliases +} + +func bootstrapIdentityMatches(request, actual map[string]any) bool { + for _, field := range []string{"siteId", "tenantId", "vpcId"} { + expected, requested := request[field] + if !requested { + continue + } + observed, present := actual[field] + if !present || !bootstrapScalarEqual(expected, observed) { + return false + } + } + return true +} + +func bootstrapValueMatches(expected, actual any) bool { + switch expectedValue := expected.(type) { + case map[string]any: + actualValue, ok := actual.(map[string]any) + if !ok { + return false + } + for key, value := range expectedValue { + observed, present := actualValue[key] + if !present || !bootstrapValueMatches(value, observed) { + return false + } + } + return true + case []any: + actualValue, ok := actual.([]any) + if !ok || len(expectedValue) != len(actualValue) { + return false + } + for index, value := range expectedValue { + if !bootstrapValueMatches(value, actualValue[index]) { + return false + } + } + return true + default: + return bootstrapScalarEqual(expected, actual) + } +} + +// bootstrapSubsetDifferences compares fields returned by the API with the +// requested fields. Write-only request fields are deliberately skipped when +// the API omits them from its response. +func bootstrapSubsetDifferences(expected, actual any, path string) []string { + switch expectedValue := expected.(type) { + case map[string]any: + actualValue, ok := actual.(map[string]any) + if !ok { + return []string{fmt.Sprintf("%s has type %T, want object", bootstrapPath(path), actual)} + } + keys := make([]string, 0, len(expectedValue)) + for key := range expectedValue { + keys = append(keys, key) + } + sort.Strings(keys) + var differences []string + for _, key := range keys { + observed, present := actualValue[key] + if !present { + continue + } + differences = append(differences, bootstrapSubsetDifferences(expectedValue[key], observed, bootstrapJoinPath(path, key))...) + } + return differences + case []any: + actualValue, ok := actual.([]any) + if !ok { + return []string{fmt.Sprintf("%s has type %T, want array", bootstrapPath(path), actual)} + } + if len(expectedValue) > len(actualValue) { + return []string{fmt.Sprintf("%s has %d items, want at least %d", bootstrapPath(path), len(actualValue), len(expectedValue))} + } + var differences []string + for index := range expectedValue { + differences = append(differences, bootstrapSubsetDifferences(expectedValue[index], actualValue[index], bootstrapJoinPath(path, strconv.Itoa(index)))...) + } + return differences + default: + if bootstrapScalarEqual(expected, actual) { + return nil + } + return []string{fmt.Sprintf("%s is %v, want %v", bootstrapPath(path), actual, expected)} + } +} + +func bootstrapScalarEqual(left, right any) bool { + if left == nil { + return right == nil + } + if right == nil { + return false + } + leftEncoded, leftIsNumber := bootstrapNumberString(left) + rightEncoded, rightIsNumber := bootstrapNumberString(right) + if leftIsNumber != rightIsNumber { + return false + } + if leftIsNumber { + leftNumber, leftIsValid := new(big.Rat).SetString(leftEncoded) + rightNumber, rightIsValid := new(big.Rat).SetString(rightEncoded) + if !leftIsValid || !rightIsValid { + return false + } + return leftNumber.Cmp(rightNumber) == 0 + } + return reflect.TypeOf(left) == reflect.TypeOf(right) && reflect.DeepEqual(left, right) +} + +func bootstrapNumberString(value any) (string, bool) { + if number, ok := value.(json.Number); ok { + return number.String(), true + } + + reflected := reflect.ValueOf(value) + kind := reflected.Kind() + switch { + case kind >= reflect.Int && kind <= reflect.Int64: + return strconv.FormatInt(reflected.Int(), 10), true + case kind >= reflect.Uint && kind <= reflect.Uintptr: + return strconv.FormatUint(reflected.Uint(), 10), true + case kind == reflect.Float32 || kind == reflect.Float64: + return strconv.FormatFloat(reflected.Float(), 'g', -1, reflected.Type().Bits()), true + default: + return "", false + } +} + +func bootstrapJoinPath(base, element string) string { + if base == "" { + return element + } + return base + "." + element +} + +func bootstrapPath(path string) string { + if path == "" { + return "value" + } + return path +} + +func (references bootstrapReferences) resolve(value any) (any, error) { + switch typed := value.(type) { + case map[string]any: + resolved := make(map[string]any, len(typed)) + for key, item := range typed { + value, err := references.resolve(item) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + resolved[key] = value + } + return resolved, nil + case []any: + resolved := make([]any, len(typed)) + for index, item := range typed { + value, err := references.resolve(item) + if err != nil { + return nil, fmt.Errorf("item %d: %w", index, err) + } + resolved[index] = value + } + return resolved, nil + case string: + matches := bootstrapRefPattern.FindAllStringSubmatchIndex(typed, -1) + lastMatchEnd := 0 + for _, match := range matches { + if bootstrapReferenceSyntaxMalformed(typed[lastMatchEnd:match[0]]) { + return nil, fmt.Errorf("%w: malformed reference in %q", errBootstrapReference, typed) + } + lastMatchEnd = match[1] + } + if bootstrapReferenceSyntaxMalformed(typed[lastMatchEnd:]) { + return nil, fmt.Errorf("%w: malformed reference in %q", errBootstrapReference, typed) + } + if len(matches) == 0 { + return typed, nil + } + if len(matches) == 1 && matches[0][0] == 0 && matches[0][1] == len(typed) { + return references.lookup(typed[matches[0][2]:matches[0][3]]) + } + + var result strings.Builder + last := 0 + for _, match := range matches { + result.WriteString(typed[last:match[0]]) + resolved, err := references.lookup(typed[match[2]:match[3]]) + if err != nil { + return nil, err + } + result.WriteString(fmt.Sprint(resolved)) + last = match[1] + } + result.WriteString(typed[last:]) + return result.String(), nil + default: + return value, nil + } +} + +func bootstrapReferenceSyntaxMalformed(value string) bool { + return strings.Contains(value, "${") || strings.Contains(value, "}") +} + +func (references bootstrapReferences) lookup(reference string) (any, error) { + parts := strings.Split(reference, ".") + if len(parts) == 0 || parts[0] == "" { + return nil, fmt.Errorf("%w: empty reference %q", errBootstrapReference, reference) + } + var current any = map[string]any(references) + for _, part := range parts { + switch typed := current.(type) { + case map[string]any: + value, ok := typed[part] + if !ok { + return nil, fmt.Errorf("%w: %q does not exist", errBootstrapReference, reference) + } + current = value + case []any: + index, err := strconv.Atoi(part) + if err != nil || index < 0 || index >= len(typed) { + return nil, fmt.Errorf("%w: %q does not exist", errBootstrapReference, reference) + } + current = typed[index] + default: + return nil, fmt.Errorf("%w: %q does not exist", errBootstrapReference, reference) + } + } + if current == nil { + return nil, fmt.Errorf("%w: %q resolved to null", errBootstrapReference, reference) + } + return current, nil +} + +func decodeBootstrapObject(body []byte) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var response map[string]any + if err := decoder.Decode(&response); err != nil { + return nil, fmt.Errorf("decoding object: %w", err) + } + if response == nil { + return nil, fmt.Errorf("%w: API returned null", errBootstrapResponse) + } + return response, nil +} + +func decodeBootstrapList(body []byte) ([]map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var response []map[string]any + if err := decoder.Decode(&response); err != nil { + return nil, fmt.Errorf("decoding list: %w", err) + } + if response == nil { + response = []map[string]any{} + } + return response, nil +} + +func bootstrapResponseID(response map[string]any) (string, error) { + id, ok := response["id"].(string) + if !ok || id == "" { + return "", fmt.Errorf("%w: response does not contain a non-empty string id", errBootstrapResponse) + } + return id, nil +} + +func isBootstrapNotFound(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound +} diff --git a/rest-api/cli/pkg/site_bootstrap_test.go b/rest-api/cli/pkg/site_bootstrap_test.go new file mode 100644 index 0000000000..686eea6ec6 --- /dev/null +++ b/rest-api/cli/pkg/site_bootstrap_test.go @@ -0,0 +1,753 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSitePrerequisiteManifestValidation(t *testing.T) { + valid := ` +provider: + org: provider-org +tenant: + org: tenant-org +site: + request: + name: test-site +` + + tests := []struct { + name string + manifest string + errString string + }{ + {name: "valid", manifest: valid}, + {name: "tenant org required", manifest: strings.Replace(valid, "tenant-org", "", 1), errString: "tenant.org is required"}, + {name: "site required", manifest: strings.Replace(valid, "site:\n request:\n name: test-site\n", "", 1), errString: "site is required"}, + {name: "resource name required", manifest: strings.Replace(valid, "name: test-site", "description: missing-name", 1), errString: "site.request.name is required"}, + {name: "manual IP blocks are not supported", manifest: valid + "ipBlocks:\n fabric:\n request:\n name: manual\n", errString: "field ipBlocks not found"}, + {name: "site IP block selector required", manifest: valid + "siteIpBlocks: {}\n", errString: "siteIpBlocks.id or siteIpBlocks.match is required"}, + {name: "unknown field", manifest: valid + "unknown: true\n", errString: "field unknown not found"}, + {name: "multiple documents", manifest: valid + "---\n{}\n", errString: "multiple YAML documents"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manifest, err := readSitePrerequisiteManifest("-", strings.NewReader(test.manifest)) + if test.errString == "" { + require.NoError(t, err) + assert.Equal(t, "test-site", manifest.Site.Request["name"]) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.errString) + }) + } +} + +func TestSitePrerequisiteExampleManifestParses(t *testing.T) { + manifest, err := readSitePrerequisiteManifest("../examples/site-prerequisites.yaml", nil) + require.NoError(t, err) + assert.Equal(t, "sjc4", manifest.Site.Request["name"]) + assert.Contains(t, manifest.Instances, "worker") +} + +func TestResolveBootstrapValue(t *testing.T) { + context := map[string]any{ + "site": map[string]any{"id": "site-1"}, + "allocations": map[string]any{ + "network": map[string]any{ + "allocationConstraints": []any{ + map[string]any{"derivedResourceId": "ipblock-derived-1"}, + }, + }, + }, + } + + tests := []struct { + name string + input any + expected any + errorIs error + errString string + }{ + {name: "whole value", input: "${site.id}", expected: "site-1"}, + {name: "embedded value", input: "site-${site.id}", expected: "site-site-1"}, + {name: "array traversal", input: "${allocations.network.allocationConstraints.0.derivedResourceId}", expected: "ipblock-derived-1"}, + {name: "nested object", input: map[string]any{"siteId": "${site.id}"}, expected: map[string]any{"siteId": "site-1"}}, + {name: "missing reference", input: "${vpcs.default.id}", errorIs: errBootstrapReference, errString: "does not exist"}, + {name: "invalid array index", input: "${allocations.network.allocationConstraints.4.derivedResourceId}", errorIs: errBootstrapReference, errString: "does not exist"}, + {name: "unclosed reference", input: "site-${site.id", errorIs: errBootstrapReference, errString: "malformed reference"}, + {name: "unmatched closing brace", input: "site-${site.id}}", errorIs: errBootstrapReference, errString: "malformed reference"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := bootstrapReferences(context).resolve(test.input) + if test.errString == "" { + require.NoError(t, err) + assert.Equal(t, test.expected, actual) + return + } + require.Error(t, err) + require.ErrorIs(t, err, test.errorIs) + assert.Contains(t, err.Error(), test.errString) + }) + } +} + +func TestEnsureBootstrapResourceRevalidatesResolvedRequest(t *testing.T) { + manifest := completeBootstrapTestManifest() + resource := &bootstrapResource{Request: map[string]any{"name": "${site}"}} + context := map[string]any{"site": map[string]any{"id": "site-1"}} + client := NewClient("http://invalid.example", "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, new(bytes.Buffer)) + bootstrap.references = context + + _, err := bootstrap.ensureResource(bootstrap.operations.instanceType, "compute", resource) + require.ErrorIs(t, err, errInvalidBootstrapResource) + assert.Contains(t, err.Error(), "instanceTypes.compute.request.name is required") +} + +func TestEnsureBootstrapResourcePreservesDriftAfterConflict(t *testing.T) { + requests := []string{} + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + requests = append(requests, request.Method) + + switch request.Method { + case http.MethodGet: + if len(requests) == 1 { + writeBootstrapTestJSON(response, []map[string]any{}) + return + } + writeBootstrapTestJSON(response, []map[string]any{{ + "id": "site-existing", + "name": "test-site", + "description": "different description", + }}) + case http.MethodPost: + response.WriteHeader(http.StatusConflict) + writeBootstrapTestJSON(response, map[string]any{"message": "already exists"}) + default: + response.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + resource := &bootstrapResource{Request: map[string]any{ + "name": "test-site", + "description": "expected description", + }} + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, new(bytes.Buffer)) + + _, err := bootstrap.ensureResource(bootstrap.operations.instanceType, "compute", resource) + require.ErrorIs(t, err, errBootstrapDrift) + assert.Contains(t, err.Error(), "description is different description, want expected description") + assert.Equal(t, []string{http.MethodGet, http.MethodPost, http.MethodGet}, requests) +} + +func TestBootstrapScalarEqual(t *testing.T) { + tests := []struct { + name string + left any + right any + expected bool + }{ + {name: "same strings", left: "true", right: "true", expected: true}, + {name: "string and boolean", left: "true", right: true, expected: false}, + {name: "string and number", left: "1", right: 1, expected: false}, + {name: "same booleans", left: true, right: true, expected: true}, + {name: "different booleans", left: true, right: false, expected: false}, + {name: "compatible integer types", left: int32(1), right: int64(1), expected: true}, + {name: "integer and JSON number", left: uint(1), right: json.Number("1"), expected: true}, + {name: "integer and decimal", left: 1, right: 1.0, expected: true}, + {name: "different numbers", left: json.Number("1.5"), right: float64(2), expected: false}, + {name: "invalid JSON numbers", left: json.Number("invalid"), right: json.Number("invalid"), expected: false}, + {name: "both nil", expected: true}, + {name: "one nil", right: "", expected: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, bootstrapScalarEqual(test.left, test.right)) + }) + } +} + +func TestBootstrapSitePrerequisitesCreatesAndReusesResources(t *testing.T) { + api := newBootstrapTestAPI() + api.addSite("provider-org", "test-site") + api.put("provider-org", "ipblock", map[string]any{ + "id": "site-ipblock-1", + "name": "site-fabric-ipv4-10-0-0-0-16", + "siteId": "site-1", + "routingType": "DatacenterOnly", + "prefix": "10.0.0.0", + "prefixLength": 16, + }) + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + client := NewClient(server.URL, "original-org", "token", nil, false) + var progress bytes.Buffer + bootstrap := newTestSiteBootstrap(t, client, manifest, &progress) + + require.NoError(t, bootstrap.apply()) + assert.Equal(t, "original-org", client.Org) + require.NotEmpty(t, api.getOrder) + assert.Equal(t, "provider-org/service-account/current", api.getOrder[0]) + assert.Equal(t, []string{ + "provider-org/instance/type", + "provider-org/allocation", + "tenant-org/vpc", + "tenant-org/vpc-prefix", + "tenant-org/instance", + }, api.postOrder) + assert.Equal(t, "provider-id", manifest.Provider.ID) + assert.Equal(t, "tenant-id", manifest.Tenant.ID) + assert.Equal(t, "site-1", manifest.Site.ID) + assert.Equal(t, "site-ipblock-1", manifest.SiteIPBlocks.ID) + assert.Equal(t, "instance-type-1", manifest.InstanceTypes["compute"].ID) + assert.Equal(t, "allocation-1", manifest.Allocations["network"].ID) + assert.Equal(t, "vpc-1", manifest.VPCs["tenant"].ID) + assert.Equal(t, "vpc-prefix-1", manifest.VPCPrefixes["tenant"].ID) + assert.Equal(t, "instance-1", manifest.Instances["worker"].ID) + + vpcPrefixRequest := api.postRequest("tenant-org/vpc-prefix") + assert.Equal(t, "vpc-1", vpcPrefixRequest["vpcId"]) + assert.Equal(t, "tenant-ipblock-1", vpcPrefixRequest["ipBlockId"]) + instanceRequest := api.postRequest("tenant-org/instance") + assert.Equal(t, "tenant-id", instanceRequest["tenantId"]) + assert.Equal(t, "instance-type-1", instanceRequest["instanceTypeId"]) + assert.Equal(t, "vpc-1", instanceRequest["vpcId"]) + + firstPostCount := len(api.postOrder) + progress.Reset() + bootstrap = newTestSiteBootstrap(t, client, manifest, &progress) + require.NoError(t, bootstrap.apply()) + assert.Len(t, api.postOrder, firstPostCount) + assert.Contains(t, progress.String(), "reused site test-site (site-1)") + assert.Contains(t, progress.String(), "reused instance worker-1 (instance-1)") +} + +func TestBootstrapSitePrerequisitesRecoversWhenRecordedIDIsMissing(t *testing.T) { + api := newBootstrapTestAPI() + api.addSite("provider-org", "test-site") + api.addSiteIPBlock("provider-org") + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + manifest.Site.ID = "site-from-another-installation" + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + + require.NoError(t, bootstrap.apply()) + assert.Equal(t, "site-1", manifest.Site.ID) + assert.NotContains(t, api.postOrder, "provider-org/site") +} + +func TestBootstrapSitePrerequisitesRejectsMissingSite(t *testing.T) { + api := newBootstrapTestAPI() + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + + err := bootstrap.apply() + require.ErrorIs(t, err, errInvalidBootstrapResource) + assert.Contains(t, err.Error(), "required site \"test-site\" was not found") + assert.NotContains(t, api.postOrder, "provider-org/site") +} + +func TestBootstrapSitePrerequisitesRejectsExistingResourceDrift(t *testing.T) { + api := newBootstrapTestAPI() + api.put("provider-org", "site", map[string]any{ + "id": "site-existing", + "name": "test-site", + "description": "different description", + }) + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + manifest.Site.Request["description"] = "expected description" + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + + err := bootstrap.apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "existing site \"site\" does not match") + assert.Contains(t, err.Error(), "description is different description, want expected description") + assert.Empty(t, api.postOrder) +} + +func TestNewAppIncludesSiteBootstrapCommand(t *testing.T) { + app, err := NewApp(openapi.Spec) + require.NoError(t, err) + + var siteCommandFound bool + var bootstrapCommandFound bool + for _, command := range app.Commands { + if command.Name != "site" { + continue + } + siteCommandFound = true + for _, subcommand := range command.Subcommands { + if subcommand.Name == "bootstrap" { + bootstrapCommandFound = true + break + } + } + break + } + assert.True(t, siteCommandFound) + assert.True(t, bootstrapCommandFound) +} + +func TestSiteBootstrapResolvesEmbeddedOpenAPIOperations(t *testing.T) { + spec, err := ParseSpec(openapi.Spec) + require.NoError(t, err) + operations, err := newSiteBootstrapOperations(spec) + require.NoError(t, err) + + assert.Equal(t, "get-current-service-account", operations.serviceAccount.op.OperationID) + assert.Nil(t, operations.site.create.op) + assert.Equal(t, "get-all-ipblock", operations.siteIPBlock.list.op.OperationID) + assert.Nil(t, operations.siteIPBlock.create.op) + assert.Equal(t, "create-instance", operations.instance.create.op.OperationID) +} + +func TestSiteBootstrapUsesPathsFromOpenAPISpec(t *testing.T) { + spec, err := ParseSpec(openapi.Spec) + require.NoError(t, err) + + const originalPath = "/v2/org/{org}/nico/site" + const replacementPath = "/custom/org/{org}/site" + pathItem := spec.Paths[originalPath] + delete(spec.Paths, originalPath) + spec.Paths[replacementPath] = pathItem + + operations, err := newSiteBootstrapOperations(spec) + require.NoError(t, err) + assert.Equal(t, replacementPath, operations.site.list.path) +} + +func TestBootstrapSitePrerequisitesUsesServiceAccountInitialization(t *testing.T) { + api := newBootstrapTestAPI() + api.serviceAccountEnabled = true + api.addSite("service-org", "service-site") + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := &sitePrerequisiteManifest{ + Provider: bootstrapOrganization{Org: "service-org"}, + Tenant: bootstrapOrganization{Org: "service-org"}, + Site: &bootstrapResource{Request: map[string]any{"name": "service-site"}}, + } + client := NewClient(server.URL, "service-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + + require.NoError(t, bootstrap.apply()) + assert.Equal(t, "service-provider-id", manifest.Provider.ID) + assert.Equal(t, "service-tenant-id", manifest.Tenant.ID) + assert.Contains(t, api.getOrder, "service-org/service-account/current") + assert.NotContains(t, api.getOrder, "service-org/infrastructure-provider/current") + assert.NotContains(t, api.getOrder, "service-org/tenant/current") +} + +func TestBootstrapSitePrerequisitesRejectsSplitOrganizationsInServiceAccountMode(t *testing.T) { + api := newBootstrapTestAPI() + api.serviceAccountEnabled = true + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := &sitePrerequisiteManifest{ + Provider: bootstrapOrganization{Org: "provider-org"}, + Tenant: bootstrapOrganization{Org: "tenant-org"}, + Site: &bootstrapResource{Request: map[string]any{"name": "service-site"}}, + } + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + + err := bootstrap.apply() + require.ErrorIs(t, err, errInvalidBootstrapManifest) + assert.Contains(t, err.Error(), "service account mode requires provider.org and tenant.org to match") + assert.Empty(t, api.postOrder) +} + +func TestBootstrapSitePrerequisitesWaitsForAutoCreatedSiteIPBlock(t *testing.T) { + api := newBootstrapTestAPI() + api.addSite("provider-org", "test-site") + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + + err := bootstrap.apply() + require.ErrorIs(t, err, errInvalidBootstrapResource) + assert.Contains(t, err.Error(), "wait for Site fabric-prefix inventory and rerun") + assert.NotContains(t, api.postOrder, "provider-org/ipblock") +} + +func TestDiscoverExistingResourceByIDOnly(t *testing.T) { + api := newBootstrapTestAPI() + api.addSiteIPBlock("provider-org") + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + resource := &bootstrapExistingResource{ID: "site-ipblock-1"} + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + + response, err := bootstrap.discoverExistingResource(bootstrap.operations.siteIPBlock, "fabric", resource) + require.NoError(t, err) + assert.Equal(t, "site-ipblock-1", response["id"]) + assert.Equal(t, []string{"provider-org/ipblock/site-ipblock-1"}, api.getOrder) +} + +func TestDiscoverExistingResourceFallsBackFromStaleIDToMatch(t *testing.T) { + api := newBootstrapTestAPI() + api.addSiteIPBlock("provider-org") + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + manifest := completeBootstrapTestManifest() + resource := manifest.SiteIPBlocks + resource.ID = "stale-site-ipblock" + client := NewClient(server.URL, "provider-org", "token", nil, false) + bootstrap := newTestSiteBootstrap(t, client, manifest, nil) + bootstrap.references["site"] = map[string]any{"id": "site-1"} + + response, err := bootstrap.discoverExistingResource(bootstrap.operations.siteIPBlock, "fabric", resource) + require.NoError(t, err) + assert.Equal(t, "site-ipblock-1", response["id"]) + assert.Equal(t, "site-ipblock-1", resource.ID) + assert.Equal(t, []string{ + "provider-org/ipblock/stale-site-ipblock", + "provider-org/ipblock", + }, api.getOrder) +} + +func TestSiteBootstrapCommandWritesReplayableManifest(t *testing.T) { + api := newBootstrapTestAPI() + api.addSite("provider-org", "command-site") + server := httptest.NewServer(api) + t.Cleanup(server.Close) + + input := ` +provider: + org: provider-org +tenant: + org: tenant-org +site: + request: + name: command-site +` + app, err := NewApp(openapi.Spec) + require.NoError(t, err) + var stdout bytes.Buffer + var stderr bytes.Buffer + app.Reader = strings.NewReader(input) + app.Writer = &stdout + app.ErrWriter = &stderr + + err = app.Run([]string{ + "nicocli", + "--base-url", server.URL, + "--token", "test-token", + "site", "bootstrap", + "--file", "-", + "--output-file", "-", + }) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "reused site command-site (site-1)") + + resolved, err := readSitePrerequisiteManifest("-", strings.NewReader(stdout.String())) + require.NoError(t, err) + assert.Equal(t, "provider-org", resolved.Provider.Org) + assert.Equal(t, "provider-id", resolved.Provider.ID) + assert.Equal(t, "tenant-id", resolved.Tenant.ID) + assert.Equal(t, "site-1", resolved.Site.ID) +} + +func completeBootstrapTestManifest() *sitePrerequisiteManifest { + return &sitePrerequisiteManifest{ + Provider: bootstrapOrganization{Org: "provider-org"}, + Tenant: bootstrapOrganization{Org: "tenant-org"}, + Site: &bootstrapResource{Request: map[string]any{ + "name": "test-site", + }}, + SiteIPBlocks: &bootstrapExistingResource{Match: map[string]any{ + "siteId": "${site.id}", + "routingType": "DatacenterOnly", + "prefix": "10.0.0.0", + "prefixLength": 16, + }}, + InstanceTypes: map[string]*bootstrapResource{ + "compute": {Request: map[string]any{ + "name": "compute-large", + "siteId": "${site.id}", + "machineCapabilities": []any{}, + }}, + }, + Allocations: map[string]*bootstrapResource{ + "network": {Request: map[string]any{ + "name": "tenant-network", + "tenantId": "${tenant.id}", + "siteId": "${site.id}", + "allocationConstraints": []any{ + map[string]any{ + "resourceType": "IPBlock", + "resourceTypeId": "${siteIpBlocks.id}", + "constraintType": "OnDemand", + "constraintValue": 24, + }, + }, + }}, + }, + VPCs: map[string]*bootstrapResource{ + "tenant": {Request: map[string]any{ + "name": "tenant-vpc", + "siteId": "${site.id}", + }}, + }, + VPCPrefixes: map[string]*bootstrapResource{ + "tenant": {Request: map[string]any{ + "name": "tenant-prefix", + "vpcId": "${vpcs.tenant.id}", + "ipBlockId": "${allocations.network.allocationConstraints.0.derivedResourceId}", + "prefixLength": 24, + }}, + }, + Instances: map[string]*bootstrapResource{ + "worker": {Request: map[string]any{ + "name": "worker-1", + "tenantId": "${tenant.id}", + "instanceTypeId": "${instanceTypes.compute.id}", + "vpcId": "${vpcs.tenant.id}", + "interfaces": []any{ + map[string]any{ + "vpcPrefixId": "${vpcPrefixes.tenant.id}", + "isPhysical": true, + }, + }, + }}, + }, + } +} + +func newTestSiteBootstrap(t *testing.T, client *Client, manifest *sitePrerequisiteManifest, progress io.Writer) *siteBootstrap { + t.Helper() + + spec, err := ParseSpec(openapi.Spec) + require.NoError(t, err) + bootstrap, err := newSiteBootstrap(spec, client, manifest, progress) + require.NoError(t, err) + return bootstrap +} + +type bootstrapTestAPI struct { + resources map[string]map[string]map[string]any + postOrder []string + getOrder []string + postBodies map[string][]map[string]any + nextIDByKey map[string]int + serviceAccountEnabled bool +} + +func newBootstrapTestAPI() *bootstrapTestAPI { + return &bootstrapTestAPI{ + resources: map[string]map[string]map[string]any{}, + postBodies: map[string][]map[string]any{}, + nextIDByKey: map[string]int{}, + } +} + +func (api *bootstrapTestAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/v2/org/"), "/nico/") + if len(parts) != 2 { + http.NotFound(response, request) + return + } + org, resourcePath := parts[0], parts[1] + if request.Method == http.MethodGet { + api.getOrder = append(api.getOrder, org+"/"+resourcePath) + } + + if request.Method == http.MethodGet && resourcePath == "service-account/current" { + payload := map[string]any{"enabled": api.serviceAccountEnabled} + if api.serviceAccountEnabled { + payload["infrastructureProviderId"] = "service-provider-id" + payload["tenantId"] = "service-tenant-id" + } + writeBootstrapTestJSON(response, payload) + return + } + + if request.Method == http.MethodGet && resourcePath == "infrastructure-provider/current" { + writeBootstrapTestJSON(response, map[string]any{"id": "provider-id", "org": org}) + return + } + if request.Method == http.MethodGet && resourcePath == "tenant/current" { + writeBootstrapTestJSON(response, map[string]any{"id": "tenant-id", "org": org}) + return + } + + collection, id := splitBootstrapTestResourcePath(resourcePath) + if collection == "" { + http.NotFound(response, request) + return + } + + switch request.Method { + case http.MethodGet: + if id != "" { + item := api.get(org, collection, id) + if item == nil { + response.WriteHeader(http.StatusNotFound) + writeBootstrapTestJSON(response, map[string]any{"message": "not found"}) + return + } + writeBootstrapTestJSON(response, item) + return + } + name := request.URL.Query().Get("query") + items := []map[string]any{} + for _, item := range api.collection(org, collection) { + if name == "" || strings.Contains(fmt.Sprint(item["name"]), name) { + items = append(items, item) + } + } + writeBootstrapTestJSON(response, items) + case http.MethodPost: + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + http.Error(response, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) + return + } + key := org + "/" + collection + api.nextIDByKey[key]++ + prefix := strings.ReplaceAll(collection, "/", "-") + id := fmt.Sprintf("%s-%d", prefix, api.nextIDByKey[key]) + item := cloneBootstrapTestMap(body) + item["id"] = id + if collection == "allocation" { + constraints, _ := item["allocationConstraints"].([]any) + for index, rawConstraint := range constraints { + constraint, _ := rawConstraint.(map[string]any) + constraint["id"] = fmt.Sprintf("constraint-%d", index+1) + constraint["allocationId"] = id + if constraint["resourceType"] == "IPBlock" { + constraint["derivedResourceId"] = "tenant-ipblock-1" + } + } + } + api.put(org, collection, item) + api.postOrder = append(api.postOrder, key) + api.postBodies[key] = append(api.postBodies[key], cloneBootstrapTestMap(body)) + response.WriteHeader(http.StatusCreated) + writeBootstrapTestJSON(response, item) + default: + response.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func splitBootstrapTestResourcePath(resourcePath string) (string, string) { + for _, collection := range []string{"instance/type", "vpc-prefix", "ipblock", "allocation", "instance", "site", "vpc"} { + if resourcePath == collection { + return collection, "" + } + if strings.HasPrefix(resourcePath, collection+"/") { + return collection, strings.TrimPrefix(resourcePath, collection+"/") + } + } + return "", "" +} + +func (api *bootstrapTestAPI) collection(org, collection string) map[string]map[string]any { + key := org + "/" + collection + if api.resources[key] == nil { + api.resources[key] = map[string]map[string]any{} + } + return api.resources[key] +} + +func (api *bootstrapTestAPI) put(org, collection string, item map[string]any) { + api.collection(org, collection)[fmt.Sprint(item["id"])] = cloneBootstrapTestMap(item) +} + +func (api *bootstrapTestAPI) addSite(org, name string) { + api.put(org, "site", map[string]any{ + "id": "site-1", + "name": name, + }) +} + +func (api *bootstrapTestAPI) addSiteIPBlock(org string) { + api.put(org, "ipblock", map[string]any{ + "id": "site-ipblock-1", + "name": "site-fabric-ipv4-10-0-0-0-16", + "siteId": "site-1", + "routingType": "DatacenterOnly", + "prefix": "10.0.0.0", + "prefixLength": 16, + }) +} + +func (api *bootstrapTestAPI) get(org, collection, id string) map[string]any { + item := api.collection(org, collection)[id] + if item == nil { + return nil + } + return cloneBootstrapTestMap(item) +} + +func (api *bootstrapTestAPI) postRequest(key string) map[string]any { + requests := api.postBodies[key] + if len(requests) == 0 { + return nil + } + return requests[len(requests)-1] +} + +func cloneBootstrapTestMap(value map[string]any) map[string]any { + data, err := json.Marshal(value) + if err != nil { + panic(err) + } + var cloned map[string]any + if err := json.Unmarshal(data, &cloned); err != nil { + panic(err) + } + return cloned +} + +func writeBootstrapTestJSON(response http.ResponseWriter, value any) { + if err := json.NewEncoder(response).Encode(value); err != nil { + panic(err) + } +}