From 98b47c860f1bcd64f4214d5f8c02fc46d5d6a91f Mon Sep 17 00:00:00 2001 From: Vladik Romanovsky Date: Thu, 18 Jun 2026 16:44:34 -0400 Subject: [PATCH] OSAC-1419: add script for seeding default catalog items Assisted-by: Claude Code Signed-off-by: Vladik Romanovsky --- examples/catalog-items/README.md | 70 +++ examples/catalog-items/linux-vm.yaml | 40 ++ .../ocp-4-20-nico-baremetal-cluster.yaml | 20 + .../simple-ocp-4-17-cluster.yaml | 20 + examples/catalog-items/windows-vm.yaml | 40 ++ internal/cmd/osac-dev/root_cmd.go | 2 + .../seed_catalog_items_cmd.go | 483 ++++++++++++++++++ it/it_catalog_items_test.go | 266 ++++++++++ 8 files changed, 941 insertions(+) create mode 100644 examples/catalog-items/README.md create mode 100644 examples/catalog-items/linux-vm.yaml create mode 100644 examples/catalog-items/ocp-4-20-nico-baremetal-cluster.yaml create mode 100644 examples/catalog-items/simple-ocp-4-17-cluster.yaml create mode 100644 examples/catalog-items/windows-vm.yaml create mode 100644 internal/cmd/osac-dev/seedcatalogitems/seed_catalog_items_cmd.go create mode 100644 it/it_catalog_items_test.go diff --git a/examples/catalog-items/README.md b/examples/catalog-items/README.md new file mode 100644 index 000000000..3283e30d4 --- /dev/null +++ b/examples/catalog-items/README.md @@ -0,0 +1,70 @@ +# Catalog Item Examples + +This directory contains example catalog items that can be created in the OSAC fulfillment service. These YAML files can be used directly with `osac create -f` for one-off creation, or as reference for the `osac-dev seed-catalog-items` idempotent seeding command. + +## Contents + +- `simple-ocp-4-17-cluster.yaml` — Simple OpenShift 4.17 cluster (fc430 hardware) +- `ocp-4-20-nico-baremetal-cluster.yaml` — OpenShift 4.20 cluster on NICo bare metal +- `linux-vm.yaml` — General-purpose Linux VM +- `windows-vm.yaml` — Windows VM + +## Usage + +### Using the osac CLI + +Catalog items are part of the **private API**, so you must first log in with the `--private` flag to enable private API access: + +```bash +# Log in with private API access enabled +osac login --private ... + +# Create each catalog item +osac create -f simple-ocp-4-17-cluster.yaml +osac create -f ocp-4-20-nico-baremetal-cluster.yaml +osac create -f linux-vm.yaml +osac create -f windows-vm.yaml +``` + +**Note:** The `osac create -f` command is **not idempotent** — it will fail if the catalog item already exists. + +### Using the osac-dev CLI (idempotent) + +For idempotent seeding (safe to run multiple times), use the `osac-dev seed-catalog-items` subcommand: + +```bash +go run ./cmd/osac-dev seed-catalog-items \ + --api-url=fulfillment-api.osac.svc.cluster.local:443 \ + --token=$(kubectl create token -n osac client) \ + --insecure +``` + +This command: +- Creates all 4 catalog items if they don't exist +- Skips items that already exist (by name) +- Connects directly to the gRPC API + +## Authentication + +Both methods require authentication: + +- **osac CLI**: Set up authentication with `osac login` first, or use `--token` flag +- **osac-dev CLI**: Requires `--api-url` and `--token` flags + +Example token generation for development: + +```bash +kubectl create token -n osac client +``` + +## File Format + +These YAML files use the protobuf `Any` encoding format required by `osac create -f`. Each file includes: + +- `@type` field: Identifies the protobuf message type (e.g., `type.googleapis.com/osac.private.v1.ClusterCatalogItem`) +- `metadata.name`: Unique identifier for the catalog item +- `title`: Human-friendly display name +- `description`: Detailed description (supports Markdown) +- `template`: Template identifier this catalog item references +- `published`: Whether visible in the public API +- `field_definitions`: List of user-editable fields with validation schemas diff --git a/examples/catalog-items/linux-vm.yaml b/examples/catalog-items/linux-vm.yaml new file mode 100644 index 000000000..ad06ac63e --- /dev/null +++ b/examples/catalog-items/linux-vm.yaml @@ -0,0 +1,40 @@ +# Linux Virtual Machine catalog item +# A general-purpose Linux virtual machine with customizable resources. + +"@type": type.googleapis.com/osac.private.v1.ComputeInstanceCatalogItem +metadata: + name: linux-vm +title: Linux Virtual Machine +description: A general-purpose Linux virtual machine with customizable resources. +template: osac.templates.ocp_virt_vm +published: true +field_definitions: + - path: instance_type + display_name: Instance Type + editable: true + default: cx1.2xlarge + validation_schema: '{"type":"string","minLength":1}' + - path: boot_disk.size_gib + display_name: Boot Disk Size (GiB) + editable: true + default: 120 + validation_schema: '{"type":"integer","minimum":10,"maximum":1024}' + - path: image.source_ref + display_name: Container Disk Image + editable: true + default: quay.io/containerdisks/fedora:latest + validation_schema: '{"type":"string","pattern":"^[a-z0-9./-]+:[a-z0-9._-]+$"}' + - path: image.source_type + display_name: Image Source Type + editable: false + default: registry + validation_schema: '{"type":"string"}' + - path: run_strategy + display_name: Run Strategy + editable: true + default: Always + validation_schema: '{"type":"string","enum":["Always","Halted"]}' + - path: user_data + display_name: Cloud Init User Data + editable: true + validation_schema: '{"type":"string"}' diff --git a/examples/catalog-items/ocp-4-20-nico-baremetal-cluster.yaml b/examples/catalog-items/ocp-4-20-nico-baremetal-cluster.yaml new file mode 100644 index 000000000..671fd3afe --- /dev/null +++ b/examples/catalog-items/ocp-4-20-nico-baremetal-cluster.yaml @@ -0,0 +1,20 @@ +# OpenShift 4.20 Cluster (NICo Bare Metal) catalog item +# An OpenShift 4.20 cluster on NICo bare metal infrastructure with DGX nodes. +# Optimized for GPU workloads and high-performance computing. + +"@type": type.googleapis.com/osac.private.v1.ClusterCatalogItem +metadata: + name: ocp-4-20-nico-baremetal-cluster +title: OpenShift 4.20 Cluster (NICo Bare Metal) +description: An OpenShift 4.20 cluster on NICo bare metal infrastructure with DGX nodes. Optimized for GPU workloads and high-performance computing. +template: osac.templates.ocp_4_20_small_nico +published: true +field_definitions: + - path: spec.network.pod_cidr + display_name: Pod CIDR + editable: true + validation_schema: '{"type":"string","pattern":"^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"}' + - path: spec.network.service_cidr + display_name: Service CIDR + editable: true + validation_schema: '{"type":"string","pattern":"^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"}' diff --git a/examples/catalog-items/simple-ocp-4-17-cluster.yaml b/examples/catalog-items/simple-ocp-4-17-cluster.yaml new file mode 100644 index 000000000..825d49fdf --- /dev/null +++ b/examples/catalog-items/simple-ocp-4-17-cluster.yaml @@ -0,0 +1,20 @@ +# Simple OpenShift 4.17 Cluster catalog item +# A small OpenShift 4.17 cluster with 2 worker nodes on fc430 hardware. +# Suitable for development and testing workloads. + +"@type": type.googleapis.com/osac.private.v1.ClusterCatalogItem +metadata: + name: simple-ocp-4-17-cluster +title: Simple OpenShift 4.17 Cluster +description: A small OpenShift 4.17 cluster with 2 worker nodes on fc430 hardware. Suitable for development and testing workloads. +template: osac.templates.ocp_4_17_small +published: true +field_definitions: + - path: spec.network.pod_cidr + display_name: Pod CIDR + editable: true + validation_schema: '{"type":"string","pattern":"^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"}' + - path: spec.network.service_cidr + display_name: Service CIDR + editable: true + validation_schema: '{"type":"string","pattern":"^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"}' diff --git a/examples/catalog-items/windows-vm.yaml b/examples/catalog-items/windows-vm.yaml new file mode 100644 index 000000000..9d409aa2f --- /dev/null +++ b/examples/catalog-items/windows-vm.yaml @@ -0,0 +1,40 @@ +# Windows Virtual Machine catalog item +# A Windows virtual machine with customizable resources. + +"@type": type.googleapis.com/osac.private.v1.ComputeInstanceCatalogItem +metadata: + name: windows-vm +title: Windows Virtual Machine +description: A Windows virtual machine with customizable resources. +template: osac.templates.ocp_virt_vm +published: true +field_definitions: + - path: instance_type + display_name: Instance Type + editable: true + default: cx1.2xlarge + validation_schema: '{"type":"string","minLength":1}' + - path: boot_disk.size_gib + display_name: Boot Disk Size (GiB) + editable: true + default: 120 + validation_schema: '{"type":"integer","minimum":10,"maximum":1024}' + - path: image.source_ref + display_name: Container Disk Image + editable: true + default: quay.io/containerdisks/windows-server:latest + validation_schema: '{"type":"string","pattern":"^[a-z0-9./-]+:[a-z0-9._-]+$"}' + - path: image.source_type + display_name: Image Source Type + editable: false + default: registry + validation_schema: '{"type":"string"}' + - path: run_strategy + display_name: Run Strategy + editable: true + default: Always + validation_schema: '{"type":"string","enum":["Always","Halted"]}' + - path: user_data + display_name: Cloud Init User Data + editable: true + validation_schema: '{"type":"string"}' diff --git a/internal/cmd/osac-dev/root_cmd.go b/internal/cmd/osac-dev/root_cmd.go index 616cb052b..42ac3af4c 100644 --- a/internal/cmd/osac-dev/root_cmd.go +++ b/internal/cmd/osac-dev/root_cmd.go @@ -23,6 +23,7 @@ import ( "github.com/osac-project/fulfillment-service/internal/cache" "github.com/osac-project/fulfillment-service/internal/cmd/cli/help" "github.com/osac-project/fulfillment-service/internal/cmd/osac-dev/generate" + "github.com/osac-project/fulfillment-service/internal/cmd/osac-dev/seedcatalogitems" "github.com/osac-project/fulfillment-service/internal/logging" "github.com/osac-project/fulfillment-service/internal/terminal" ) @@ -63,6 +64,7 @@ func Root() (result *cobra.Command, err error) { // Add commands: result.AddCommand(generate.Cmd()) + result.AddCommand(seedcatalogitems.Cmd()) // Configure the root command, and therefore all its subcommands, to use Markdown for their help output: help.Setup(result) diff --git a/internal/cmd/osac-dev/seedcatalogitems/seed_catalog_items_cmd.go b/internal/cmd/osac-dev/seedcatalogitems/seed_catalog_items_cmd.go new file mode 100644 index 000000000..bfd8561d7 --- /dev/null +++ b/internal/cmd/osac-dev/seedcatalogitems/seed_catalog_items_cmd.go @@ -0,0 +1,483 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +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 seedcatalogitems + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + + privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" + "github.com/osac-project/fulfillment-service/internal/auth" + "github.com/osac-project/fulfillment-service/internal/logging" + "github.com/osac-project/fulfillment-service/internal/network" + "github.com/osac-project/fulfillment-service/internal/terminal" +) + +func Cmd() *cobra.Command { + runner := &runnerContext{} + result := &cobra.Command{ + Use: "seed-catalog-items [FLAG...]", + Short: shortHelp, + Long: longHelp, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + RunE: runner.run, + } + flags := result.Flags() + flags.StringVar( + &runner.args.apiUrl, + "api-url", + "", + apiUrlFlagHelp, + ) + flags.StringVar( + &runner.args.token, + "token", + "", + tokenFlagHelp, + ) + flags.BoolVar( + &runner.args.insecure, + "insecure", + false, + insecureFlagHelp, + ) + flags.BoolVar( + &runner.args.dryRun, + "dry-run", + false, + dryRunFlagHelp, + ) + return result +} + +type runnerContext struct { + args struct { + apiUrl string + token string + insecure bool + dryRun bool + } + logger *slog.Logger + console *terminal.Console +} + +func (c *runnerContext) run(cmd *cobra.Command, args []string) error { + var err error + + // Get the context with a timeout to avoid blocking indefinitely on stalled RPCs: + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + // Get the logger and the console: + c.logger = logging.LoggerFromContext(ctx) + c.console = terminal.ConsoleFromContext(ctx) + + // Check that the API URL is provided: + if c.args.apiUrl == "" { + return fmt.Errorf("API URL is required") + } + + // Check that the token is provided: + if c.args.token == "" { + return fmt.Errorf("token is required") + } + + // Create the token source: + token := &auth.Token{ + Access: c.args.token, + } + tokenSource, err := auth.NewStaticTokenSource(). + SetLogger(c.logger). + SetToken(token). + Build() + if err != nil { + return fmt.Errorf("failed to create token source: %w", err) + } + + // Create the gRPC client connection: + conn, err := network.NewGrpcClient(). + SetLogger(c.logger). + SetAddress(c.args.apiUrl). + SetInsecure(c.args.insecure). + SetTokenSource(tokenSource). + Build() + if err != nil { + return fmt.Errorf("failed to create gRPC client: %w", err) + } + defer func() { + err := conn.Close() + if err != nil { + c.logger.WarnContext( + ctx, + "Failed to close gRPC connection", + slog.Any("error", err), + ) + } + }() + + // Create the clients: + clusterClient := privatev1.NewClusterCatalogItemsClient(conn) + computeClient := privatev1.NewComputeInstanceCatalogItemsClient(conn) + + // Seed cluster catalog items: + c.console.Infof(ctx, "→ Cluster Catalog Items\n") + created, skipped, err := c.seedClusterCatalogItems(ctx, clusterClient) + if err != nil { + return err + } + totalCreated := created + totalSkipped := skipped + + c.console.Infof(ctx, "\n") + + // Seed compute instance catalog items: + c.console.Infof(ctx, "→ Compute Instance Catalog Items\n") + created, skipped, err = c.seedComputeCatalogItems(ctx, computeClient) + if err != nil { + return err + } + totalCreated += created + totalSkipped += skipped + + c.console.Infof(ctx, "\n") + if c.args.dryRun { + c.console.Infof(ctx, "Dry run: Would create: %d | Skip: %d\n", totalCreated, totalSkipped) + } else { + c.console.Infof(ctx, "Seeding complete. Created: %d | Skipped: %d\n", totalCreated, totalSkipped) + } + + return nil +} + +// validationSchema marshals a JSON Schema map to a string. The input contains only primitive types +// (strings and ints), so json.Marshal cannot fail here. +func validationSchema(schema map[string]any) string { + bytes, err := json.Marshal(schema) + if err != nil { + panic(fmt.Sprintf("failed to marshal validation schema: %v", err)) + } + return string(bytes) +} + +func cidrValidationSchema() string { + return validationSchema(map[string]any{ + "type": "string", + "pattern": `^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$`, + }) +} + +// vmFieldDefinitions returns common field definitions for VM catalog items. +func vmFieldDefinitions(defaultInstanceType, defaultImage string) []*privatev1.FieldDefinition { + return []*privatev1.FieldDefinition{ + { + Path: "instance_type", + DisplayName: "Instance Type", + Editable: true, + Default: structpb.NewStringValue(defaultInstanceType), + ValidationSchema: validationSchema(map[string]any{ + "type": "string", + "minLength": 1, + }), + }, + { + Path: "boot_disk.size_gib", + DisplayName: "Boot Disk Size (GiB)", + Editable: true, + Default: structpb.NewNumberValue(120), + ValidationSchema: validationSchema(map[string]any{ + "type": "integer", + "minimum": 10, + "maximum": 1024, + }), + }, + { + Path: "image.source_ref", + DisplayName: "Container Disk Image", + Editable: true, + Default: structpb.NewStringValue(defaultImage), + ValidationSchema: validationSchema(map[string]any{ + "type": "string", + "pattern": `^[a-z0-9./-]+:[a-z0-9._-]+$`, + }), + }, + { + Path: "image.source_type", + DisplayName: "Image Source Type", + Editable: false, + Default: structpb.NewStringValue("registry"), + ValidationSchema: validationSchema(map[string]any{ + "type": "string", + }), + }, + { + Path: "run_strategy", + DisplayName: "Run Strategy", + Editable: true, + Default: structpb.NewStringValue("Always"), + ValidationSchema: validationSchema(map[string]any{ + "type": "string", + "enum": []string{"Always", "Halted"}, + }), + }, + { + Path: "user_data", + DisplayName: "Cloud Init User Data", + Editable: true, + ValidationSchema: validationSchema(map[string]any{ + "type": "string", + }), + }, + } +} + +func (c *runnerContext) seedClusterCatalogItems(ctx context.Context, client privatev1.ClusterCatalogItemsClient) (created, skipped int, err error) { + cidrSchema := cidrValidationSchema() + + items := []struct { + name string + item *privatev1.ClusterCatalogItem + }{ + { + name: "simple-ocp-4-17-cluster", + item: &privatev1.ClusterCatalogItem{ + Metadata: &privatev1.Metadata{ + Name: "simple-ocp-4-17-cluster", + }, + Title: "Simple OpenShift 4.17 Cluster", + Description: "A small OpenShift 4.17 cluster with 2 worker nodes on fc430 hardware. Suitable for development and testing workloads.", + Template: "osac.templates.ocp_4_17_small", + Published: true, + FieldDefinitions: []*privatev1.FieldDefinition{ + { + Path: "spec.network.pod_cidr", + DisplayName: "Pod CIDR", + Editable: true, + ValidationSchema: cidrSchema, + }, + { + Path: "spec.network.service_cidr", + DisplayName: "Service CIDR", + Editable: true, + ValidationSchema: cidrSchema, + }, + }, + }, + }, + { + name: "ocp-4-20-nico-baremetal-cluster", + item: &privatev1.ClusterCatalogItem{ + Metadata: &privatev1.Metadata{ + Name: "ocp-4-20-nico-baremetal-cluster", + }, + Title: "OpenShift 4.20 Cluster (NICo Bare Metal)", + Description: "An OpenShift 4.20 cluster on NICo bare metal infrastructure with DGX nodes. Optimized for GPU workloads and high-performance computing.", + Template: "osac.templates.ocp_4_20_small_nico", + Published: true, + FieldDefinitions: []*privatev1.FieldDefinition{ + { + Path: "spec.network.pod_cidr", + DisplayName: "Pod CIDR", + Editable: true, + ValidationSchema: cidrSchema, + }, + { + Path: "spec.network.service_cidr", + DisplayName: "Service CIDR", + Editable: true, + ValidationSchema: cidrSchema, + }, + }, + }, + }, + } + + for _, item := range items { + itemCreated, itemSkipped, itemErr := c.createClusterItemIfMissing(ctx, client, item.name, item.item) + if itemErr != nil { + return created, skipped, itemErr + } + created += itemCreated + skipped += itemSkipped + } + + return created, skipped, nil +} + +func (c *runnerContext) seedComputeCatalogItems(ctx context.Context, client privatev1.ComputeInstanceCatalogItemsClient) (created, skipped int, err error) { + items := []struct { + name string + item *privatev1.ComputeInstanceCatalogItem + }{ + { + name: "linux-vm", + item: &privatev1.ComputeInstanceCatalogItem{ + Metadata: &privatev1.Metadata{ + Name: "linux-vm", + }, + Title: "Linux Virtual Machine", + Description: "A general-purpose Linux virtual machine with customizable CPU, memory, and disk resources.", + Template: "osac.templates.ocp_virt_vm", + Published: true, + FieldDefinitions: vmFieldDefinitions("cx1.2xlarge", "quay.io/containerdisks/fedora:latest"), + }, + }, + { + name: "windows-vm", + item: &privatev1.ComputeInstanceCatalogItem{ + Metadata: &privatev1.Metadata{ + Name: "windows-vm", + }, + Title: "Windows Virtual Machine", + Description: "A Windows virtual machine with customizable CPU and memory resources.", + Template: "osac.templates.ocp_virt_vm", + Published: true, + FieldDefinitions: vmFieldDefinitions("cx1.2xlarge", "quay.io/containerdisks/windows-server:latest"), + }, + }, + } + + for _, item := range items { + itemCreated, itemSkipped, itemErr := c.createComputeItemIfMissing(ctx, client, item.name, item.item) + if itemErr != nil { + return created, skipped, itemErr + } + created += itemCreated + skipped += itemSkipped + } + + return created, skipped, nil +} + +func (c *runnerContext) createClusterItemIfMissing(ctx context.Context, client privatev1.ClusterCatalogItemsClient, name string, item *privatev1.ClusterCatalogItem) (created, skipped int, err error) { + listResp, err := client.List(ctx, &privatev1.ClusterCatalogItemsListRequest{}) + if err != nil { + return 0, 0, fmt.Errorf("failed to list cluster catalog items: %w", err) + } + + for _, existing := range listResp.Items { + if existing.Metadata != nil && existing.Metadata.Name == name { + c.console.Infof(ctx, " Already exists: %s\n", name) + c.logger.InfoContext(ctx, "Catalog item already exists", + slog.String("name", name), + slog.String("type", "cluster")) + return 0, 1, nil + } + } + + if c.args.dryRun { + c.console.Infof(ctx, " Would create: %s\n", name) + c.logger.InfoContext(ctx, "Dry run: would create catalog item", + slog.String("name", name), + slog.String("type", "cluster")) + return 1, 0, nil + } + + _, err = client.Create(ctx, &privatev1.ClusterCatalogItemsCreateRequest{ + Object: item, + }) + if err != nil { + if st, ok := status.FromError(err); ok && st.Code() == codes.AlreadyExists { + c.console.Infof(ctx, " Already exists: %s\n", name) + c.logger.InfoContext(ctx, "Catalog item already exists (race)", + slog.String("name", name), + slog.String("type", "cluster")) + return 0, 1, nil + } + return 0, 0, fmt.Errorf("failed to create %s: %w", name, err) + } + + c.console.Infof(ctx, " Created: %s\n", name) + c.logger.InfoContext(ctx, "Created catalog item", + slog.String("name", name), + slog.String("type", "cluster")) + return 1, 0, nil +} + +func (c *runnerContext) createComputeItemIfMissing(ctx context.Context, client privatev1.ComputeInstanceCatalogItemsClient, name string, item *privatev1.ComputeInstanceCatalogItem) (created, skipped int, err error) { + listResp, err := client.List(ctx, &privatev1.ComputeInstanceCatalogItemsListRequest{}) + if err != nil { + return 0, 0, fmt.Errorf("failed to list compute instance catalog items: %w", err) + } + + for _, existing := range listResp.Items { + if existing.Metadata != nil && existing.Metadata.Name == name { + c.console.Infof(ctx, " Already exists: %s\n", name) + c.logger.InfoContext(ctx, "Catalog item already exists", + slog.String("name", name), + slog.String("type", "compute")) + return 0, 1, nil + } + } + + if c.args.dryRun { + c.console.Infof(ctx, " Would create: %s\n", name) + c.logger.InfoContext(ctx, "Dry run: would create catalog item", + slog.String("name", name), + slog.String("type", "compute")) + return 1, 0, nil + } + + _, err = client.Create(ctx, &privatev1.ComputeInstanceCatalogItemsCreateRequest{ + Object: item, + }) + if err != nil { + if st, ok := status.FromError(err); ok && st.Code() == codes.AlreadyExists { + c.console.Infof(ctx, " Already exists: %s\n", name) + c.logger.InfoContext(ctx, "Catalog item already exists (race)", + slog.String("name", name), + slog.String("type", "compute")) + return 0, 1, nil + } + return 0, 0, fmt.Errorf("failed to create %s: %w", name, err) + } + + c.console.Infof(ctx, " Created: %s\n", name) + c.logger.InfoContext(ctx, "Created catalog item", + slog.String("name", name), + slog.String("type", "compute")) + return 1, 0, nil +} + +const shortHelp = "Seed default catalog items into the OSAC fulfillment service" + +const longHelp = ` +Seed default catalog items into the OSAC fulfillment service. + +This command is idempotent and safe to run multiple times. It will skip catalog items that already exist. +` + +const apiUrlFlagHelp = ` +_URL_ - The fulfillment service API URL (e.g., {{ bt }}fulfillment-api.osac.svc.cluster.local:443{{ bt }}). +` + +const tokenFlagHelp = ` +_TOKEN_ - Bearer token for authentication. +` + +const insecureFlagHelp = ` +_[BOOLEAN]_ - Skip TLS certificate verification (for development only). +` + +const dryRunFlagHelp = ` +_[BOOLEAN]_ - Show what would be done without creating catalog items. +` diff --git a/it/it_catalog_items_test.go b/it/it_catalog_items_test.go new file mode 100644 index 000000000..59579d3b2 --- /dev/null +++ b/it/it_catalog_items_test.go @@ -0,0 +1,266 @@ +/* +Copyright (c) 2025 Red Hat Inc. + +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 it + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/types/known/structpb" + + privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" + publicv1 "github.com/osac-project/fulfillment-service/internal/api/osac/public/v1" + "github.com/osac-project/fulfillment-service/internal/uuid" +) + +var _ = Describe("Catalog items", func() { + var ( + ctx context.Context + privateClusterClient privatev1.ClusterCatalogItemsClient + privateComputeClient privatev1.ComputeInstanceCatalogItemsClient + publicClusterClient publicv1.ClusterCatalogItemsClient + clusterTemplatesClient privatev1.ClusterTemplatesClient + computeTemplatesClient privatev1.ComputeInstanceTemplatesClient + ) + + BeforeEach(func() { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + DeferCleanup(cancel) + privateClusterClient = privatev1.NewClusterCatalogItemsClient(tool.InternalView().AdminConn()) + privateComputeClient = privatev1.NewComputeInstanceCatalogItemsClient(tool.InternalView().AdminConn()) + publicClusterClient = publicv1.NewClusterCatalogItemsClient(tool.ExternalView().UserConn()) + clusterTemplatesClient = privatev1.NewClusterTemplatesClient(tool.InternalView().AdminConn()) + computeTemplatesClient = privatev1.NewComputeInstanceTemplatesClient(tool.InternalView().AdminConn()) + }) + + Describe("Cluster catalog items", func() { + var templateID string + + BeforeEach(func() { + templateID = fmt.Sprintf("test_template_%s", uuid.New()) + _, err := clusterTemplatesClient.Create(ctx, privatev1.ClusterTemplatesCreateRequest_builder{ + Object: privatev1.ClusterTemplate_builder{ + Id: templateID, + Title: "Test Template", + Description: "A test template for catalog items", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Can create a published cluster catalog item via private API", func() { + name := fmt.Sprintf("test-catalog-item-%s", uuid.New()) + + response, err := privateClusterClient.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Metadata: privatev1.Metadata_builder{ + Name: name, + }.Build(), + Title: "Simple OpenShift Cluster", + Description: "A test cluster catalog item", + Template: templateID, + Published: true, + Tenant: "", + FieldDefinitions: []*privatev1.FieldDefinition{ + privatev1.FieldDefinition_builder{ + Path: "spec.network.pod_cidr", + DisplayName: "Pod CIDR", + Editable: true, + }.Build(), + }, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + object := response.GetObject() + Expect(object).ToNot(BeNil()) + Expect(object.GetMetadata().GetName()).To(Equal(name)) + Expect(object.GetTitle()).To(Equal("Simple OpenShift Cluster")) + Expect(object.GetTemplate()).To(Equal(templateID)) + Expect(object.GetPublished()).To(BeTrue()) + Expect(object.GetTenant()).To(Equal("")) + Expect(object.GetFieldDefinitions()).To(HaveLen(1)) + }) + + It("Published catalog items appear in public API", func() { + name := fmt.Sprintf("test-catalog-item-%s", uuid.New()) + + _, err := privateClusterClient.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Metadata: privatev1.Metadata_builder{ + Name: name, + }.Build(), + Title: "Public Cluster Catalog Item", + Description: "Should be visible in public API", + Template: templateID, + Published: true, + Tenant: "", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + + listResponse, err := publicClusterClient.List(ctx, publicv1.ClusterCatalogItemsListRequest_builder{}.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(listResponse).ToNot(BeNil()) + + items := listResponse.GetItems() + found := false + for _, item := range items { + if item.GetMetadata().GetName() == name { + found = true + Expect(item.GetTitle()).To(Equal("Public Cluster Catalog Item")) + Expect(item.GetPublished()).To(BeTrue()) + break + } + } + Expect(found).To(BeTrue(), "Published catalog item should appear in public API") + }) + + It("Unpublished catalog items do not appear in public API", func() { + name := fmt.Sprintf("test-catalog-item-%s", uuid.New()) + + _, err := privateClusterClient.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Metadata: privatev1.Metadata_builder{ + Name: name, + }.Build(), + Title: "Unpublished Cluster Catalog Item", + Description: "Should NOT be visible in public API", + Template: templateID, + Published: false, + Tenant: "", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + + listResponse, err := publicClusterClient.List(ctx, publicv1.ClusterCatalogItemsListRequest_builder{}.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(listResponse).ToNot(BeNil()) + + items := listResponse.GetItems() + for _, item := range items { + Expect(item.GetMetadata().GetName()).ToNot(Equal(name), "Unpublished item should not appear in public API") + } + }) + + It("Can create catalog items with field definitions and defaults", func() { + name := fmt.Sprintf("test-catalog-item-%s", uuid.New()) + + defaultCIDR, err := structpb.NewValue("10.128.0.0/14") + Expect(err).ToNot(HaveOccurred()) + + response, err := privateClusterClient.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Metadata: privatev1.Metadata_builder{ + Name: name, + }.Build(), + Title: "Cluster with Field Definitions", + Description: "Catalog item with editable fields and validation", + Template: templateID, + Published: true, + Tenant: "", + FieldDefinitions: []*privatev1.FieldDefinition{ + privatev1.FieldDefinition_builder{ + Path: "spec.network.pod_cidr", + DisplayName: "Pod CIDR", + Editable: true, + Default: defaultCIDR, + ValidationSchema: `{"type": "string", "pattern": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"}`, + }.Build(), + }, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + + object := response.GetObject() + fieldDefs := object.GetFieldDefinitions() + Expect(fieldDefs).To(HaveLen(1)) + Expect(fieldDefs[0].GetPath()).To(Equal("spec.network.pod_cidr")) + Expect(fieldDefs[0].GetDisplayName()).To(Equal("Pod CIDR")) + Expect(fieldDefs[0].GetEditable()).To(BeTrue()) + Expect(fieldDefs[0].GetDefault().GetStringValue()).To(Equal("10.128.0.0/14")) + Expect(fieldDefs[0].GetValidationSchema()).To(ContainSubstring("pattern")) + }) + }) + + Describe("Compute instance catalog items", func() { + var templateID string + + BeforeEach(func() { + templateID = fmt.Sprintf("test_vm_template_%s", uuid.New()) + _, err := computeTemplatesClient.Create(ctx, privatev1.ComputeInstanceTemplatesCreateRequest_builder{ + Object: privatev1.ComputeInstanceTemplate_builder{ + Id: templateID, + Title: "Test VM Template", + Description: "A test VM template for catalog items", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Can create a published compute instance catalog item via private API", func() { + name := fmt.Sprintf("test-vm-catalog-item-%s", uuid.New()) + + defaultCores, err := structpb.NewValue(float64(8)) + Expect(err).ToNot(HaveOccurred()) + defaultMemory, err := structpb.NewValue(float64(64)) + Expect(err).ToNot(HaveOccurred()) + + response, err := privateComputeClient.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Metadata: privatev1.Metadata_builder{ + Name: name, + }.Build(), + Title: "Linux Virtual Machine", + Description: "A test VM catalog item", + Template: templateID, + Published: true, + Tenant: "", + FieldDefinitions: []*privatev1.FieldDefinition{ + privatev1.FieldDefinition_builder{ + Path: "spec.cores", + DisplayName: "CPU Cores", + Editable: true, + Default: defaultCores, + ValidationSchema: `{"type": "integer", "minimum": 1, "maximum": 64}`, + }.Build(), + privatev1.FieldDefinition_builder{ + Path: "spec.memory_gib", + DisplayName: "Memory (GiB)", + Editable: true, + Default: defaultMemory, + ValidationSchema: `{"type": "integer", "minimum": 1, "maximum": 512}`, + }.Build(), + }, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + + object := response.GetObject() + Expect(object).ToNot(BeNil()) + Expect(object.GetMetadata().GetName()).To(Equal(name)) + Expect(object.GetTitle()).To(Equal("Linux Virtual Machine")) + Expect(object.GetTemplate()).To(Equal(templateID)) + Expect(object.GetPublished()).To(BeTrue()) + Expect(object.GetFieldDefinitions()).To(HaveLen(2)) + }) + + }) +})