diff --git a/.gitignore b/.gitignore index 802359dd..13881562 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,9 @@ hyperfleet-operator/bin/ # Compiled operator binaries (built outside bin/) hyperfleet-operator/manager hyperfleet-operator/compactor + +# Generated passthrough raw output (regenerate with make codegen-passthrough) +*.passthrough.go.raw + +# Generated OpenAPI schemas (regenerate with make codegen-openapi) +platform-api/openapi/generated-schemas.json diff --git a/Makefile b/Makefile index 261a4340..d68c4f62 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,14 @@ .PHONY: help build test test-unit test-integration lint clean \ - build-hyperfleet-db build-operator build-api \ - test-hyperfleet-db test-operator test-operator-int test-api \ + build-hyperfleet-db build-operator build-api build-api-codegen \ + test-hyperfleet-db test-operator test-operator-int test-api test-api-codegen \ + coverage-api-codegen \ test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa test-e2e-authz \ e2e-authz-infra-up e2e-authz-infra-down e2e-init-db \ fmt vet verify deps \ manifests generate setup-envtest \ - image-api image-operator image-push-api image-push-operator + codegen-passthrough codegen-registry codegen-verify codegen-openapi verify-openapi \ + image-api image-operator image-push-api image-push-operator \ + swagger-ui-serve swagger-ui-open # ── Configuration ──────────────────────────────────────────────────────── @@ -57,10 +60,11 @@ help: @echo " build-api Platform API server" @echo " build-operator Hyperfleet operator (manager + compactor)" @echo " build-hyperfleet-db Hyperfleet DB library" + @echo " build-api-codegen API codegen tools (build-time generators)" @echo "" @echo "Test:" @echo " test All tests (unit + integration)" - @echo " test-unit Unit tests: API + operator (no external services)" + @echo " test-unit Unit tests: API + operator + codegen (no external services)" @echo " test-integration Integration tests: FleetDB + operator (podman)" @echo " test-e2e-authz E2E authz (starts local infra)" @echo " test-e2e-api E2E API" @@ -68,6 +72,8 @@ help: @echo " test-e2e-zoa E2E ZOA" @echo " test-e2e-platform-monitoring E2E monitoring" @echo "" + @echo " coverage-api-codegen Coverage report for codegen (hack/api-codegen)" + @echo "" @echo "Code Quality:" @echo " lint golangci-lint on all modules" @echo " fmt Format Go source" @@ -77,6 +83,11 @@ help: @echo "Code Generation:" @echo " manifests Generate CRD manifests" @echo " generate Generate deepcopy methods" + @echo " codegen-passthrough Run passthrough-gen (raw + curated types)" + @echo " codegen-registry Run marker-scanner (field_metadata.go/.json)" + @echo " codegen-verify Verify codegen packages compile" + @echo " codegen-openapi Generate and merge OpenAPI schemas from Go types" + @echo " verify-openapi Verify openapi.yaml matches generated schemas" @echo " setup-envtest Install envtest binaries (etcd, kube-apiserver)" @echo " deps Download and tidy all modules" @echo "" @@ -86,7 +97,7 @@ help: # ── Build ──────────────────────────────────────────────────────────────── -build: build-hyperfleet-db build-operator build-api +build: build-hyperfleet-db build-operator build-api build-api-codegen build-hyperfleet-db: cd hyperfleet-db && go build ./... @@ -98,17 +109,37 @@ build-operator: build-api: cd platform-api && go build -o ../bin/rosa-hyperfleet-api ./cmd +build-api-codegen: + cd hack/api-codegen && go build -o ../../bin/passthrough-gen ./cmd/passthrough-gen + cd hack/api-codegen && go build -o ../../bin/marker-scanner ./cmd/marker-scanner + cd hack/api-codegen && go build -o ../../bin/openapi-gen ./cmd/openapi-gen + cd hack/api-codegen && go build -o ../../bin/conversion-gen ./cmd/conversion-gen + cd hack/api-codegen && go build -o ../../bin/crd-variants ./cmd/crd-variants + cd hack/api-codegen && go build -o ../../bin/featuregate-info ./cmd/featuregate-info + cd hack/api-codegen && go build -o ../../bin/verify-configuration ./cmd/verify-configuration + cd hack/api-codegen && go build -o ../../bin/openapi-merge ./cmd/openapi-merge + # ── Test ───────────────────────────────────────────────────────────────── test: test-unit test-integration -test-unit: test-api test-operator +test-unit: test-api test-operator test-api-codegen test-integration: test-hyperfleet-db test-operator-int test-api: cd platform-api && go test -v -race -count=1 $$(go list ./... | grep -v '/test/e2e') +test-api-codegen: + cd hack/api-codegen && go test -v -race -count=1 ./... + +coverage-api-codegen: + cd hack/api-codegen && go test -race -coverprofile=coverage.out ./... + cd hack/api-codegen && go tool cover -func=coverage.out + @echo "" + @echo "HTML report: hack/api-codegen/coverage.html" + cd hack/api-codegen && go tool cover -html=coverage.out -o coverage.html + test-operator: $(SETUP_ENVTEST) @ASSETS=$$($(SETUP_ENVTEST) use -p path --bin-dir $(ENVTEST_BIN_DIR)) && \ echo "envtest assets: $$ASSETS" && \ @@ -170,16 +201,19 @@ fmt: cd hyperfleet-db && go fmt ./... cd hyperfleet-operator && go fmt ./... cd platform-api && go fmt ./... + cd hack/api-codegen && go fmt ./... vet: cd hyperfleet-db && go vet ./... cd hyperfleet-operator && go vet ./... cd platform-api && go vet ./... + cd hack/api-codegen && go vet ./... lint: $(GOLANGCI_LINT) cd hyperfleet-db && $(GOLANGCI_LINT) run --config ../.golangci.yml --timeout 5m ./... cd hyperfleet-operator && $(GOLANGCI_LINT) run --config ../.golangci.yml --timeout 5m ./... cd platform-api && $(GOLANGCI_LINT) run --config ../.golangci.yml --timeout 5m ./... + cd hack/api-codegen && $(GOLANGCI_LINT) run --config ../../.golangci.yml --timeout 5m ./... verify: cd hyperfleet-db && go mod tidy @@ -188,13 +222,15 @@ verify: cd platform-api && go mod tidy cd test && go mod tidy cd hack/tools && go mod tidy + cd hack/api-codegen && go mod tidy git diff --exit-code \ hyperfleet-db/go.mod hyperfleet-db/go.sum \ hyperfleet-operator/api/go.mod hyperfleet-operator/api/go.sum \ hyperfleet-operator/go.mod hyperfleet-operator/go.sum \ platform-api/go.mod platform-api/go.sum \ test/go.mod test/go.sum \ - hack/tools/go.mod hack/tools/go.sum + hack/tools/go.mod hack/tools/go.sum \ + hack/api-codegen/go.mod hack/api-codegen/go.sum deps: cd hyperfleet-db && go mod download && go mod tidy @@ -202,6 +238,7 @@ deps: cd hyperfleet-operator && go mod download && go mod tidy cd platform-api && go mod download && go mod tidy cd test && go mod download && go mod tidy + cd hack/api-codegen && go mod download && go mod tidy # ── Code Generation ────────────────────────────────────────────────────── @@ -216,6 +253,55 @@ ENVTEST_BIN_DIR ?= $(shell pwd)/.envtest setup-envtest: $(SETUP_ENVTEST) $(SETUP_ENVTEST) use --bin-dir $(ENVTEST_BIN_DIR) +# ── Codegen Pipeline ──────────────────────────────────────────────────── + +HYPERSHIFT_IMPORT_PATH ?= github.com/openshift/hypershift/api/hypershift/v1beta1 +HYPERSHIFT_TYPES ?= HostedClusterSpec,NodePoolSpec +V1ALPHA1_DIR := hyperfleet-operator/api/v1alpha1 +REGISTRY_DIR := platform-api/internal/codegen/registry + +codegen-passthrough: build-api-codegen + ./bin/passthrough-gen \ + --source-dir=$$(cd hyperfleet-operator/api && go list -f '{{.Dir}}' $(HYPERSHIFT_IMPORT_PATH)) \ + --types=$(HYPERSHIFT_TYPES) \ + --output-dir=$(V1ALPHA1_DIR) \ + --package=v1alpha1 \ + --registry=$(REGISTRY_DIR)/field_metadata.json + rm -f $(V1ALPHA1_DIR)/zz_generated.passthrough.go + +codegen-registry: build-api-codegen + ./bin/marker-scanner \ + --input-dirs=$(V1ALPHA1_DIR) \ + --output-file=$(REGISTRY_DIR)/field_metadata.go + +codegen-verify: build-api-codegen + cd hyperfleet-operator/api && go build ./... + cd platform-api && go build ./internal/codegen/... + +codegen-openapi: build-api-codegen + ./bin/openapi-gen \ + --input-dirs=$(V1ALPHA1_DIR) \ + --output-file=platform-api/openapi/generated-schemas.json + ./bin/openapi-merge \ + --generated=platform-api/openapi/generated-schemas.json \ + --spec=platform-api/openapi/openapi.yaml + +verify-openapi: codegen-openapi + @git diff --exit-code platform-api/openapi/openapi.yaml || \ + (echo "openapi.yaml is out of date; run 'make codegen-openapi'" && exit 1) + +swagger-ui-serve: + @command -v python3 >/dev/null 2>&1 || { echo "Error: python3 is required"; exit 1; } + @echo "Swagger UI: http://localhost:8080/platform-api/openapi/swagger-ui/" + @echo "OpenAPI spec: http://localhost:8080/platform-api/openapi/openapi.yaml" + @echo "Press Ctrl+C to stop" + @python3 -m http.server 8080 --directory . + +swagger-ui-open: + @command -v open >/dev/null 2>&1 && open http://localhost:8080/platform-api/openapi/swagger-ui/ || \ + command -v xdg-open >/dev/null 2>&1 && xdg-open http://localhost:8080/platform-api/openapi/swagger-ui/ || \ + echo "Open http://localhost:8080/platform-api/openapi/swagger-ui/ in your browser" + # ── Images ─────────────────────────────────────────────────────────────── image-api: diff --git a/hack/api-codegen/README.md b/hack/api-codegen/README.md new file mode 100644 index 00000000..a8e1b1f6 --- /dev/null +++ b/hack/api-codegen/README.md @@ -0,0 +1,90 @@ +# api-codegen + +![Coverage](https://img.shields.io/badge/coverage-31.3%25-yellow) + +Build-time code generators for the ROSA Hyperfleet API. These tools scan Go types with markers and generate passthrough types, OpenAPI schemas, CRD variants, conversion functions, and field validation metadata. + +## Generators + +| Command | Purpose | +|---------|---------| +| `passthrough-gen` | Generate passthrough struct types from HyperShift API types with `+hyperfleet:` markers | +| `marker-scanner` | Extract marker metadata into `field_metadata.go` / `field_metadata.json` (field registry) | +| `openapi-gen` | Generate Swagger 2.0 JSON schemas from annotated Go types (respects `+k8s:openapi-gen=false`) | +| `openapi-merge` | Merge generated schemas into the hand-written `openapi.yaml` (Swagger 2.0 → OAS 3.0 conversion) | +| `conversion-gen` | Generate conversion functions between API versions | +| `crd-variants` | Produce CRD variants filtered by feature gates | +| `featuregate-info` | Emit feature gate metadata for CRD fields | +| `verify-configuration` | Validate marker consistency across types | + +## Codegen pipeline + +The generators are chained in a dependency order. Each phase builds on the output of the previous one. + +```text +Phase 0: Port codegen tools into monorepo (ROSAENG-62606) +Phase 1: passthrough-gen → marker-scanner (ROSAENG-61801) + Generates typed passthrough structs with markers, + then extracts field_metadata.json registry. +Phase 2: Field validation middleware (ROSAENG-61802) + Uses field_metadata.json to enforce write-mode + (mutable/immutable/service-set) and feature gates + on create/update requests. +Phase 3: Conversion functions (ROSAENG-61803) + Typed service-set injection and preservation functions + replace hardcoded field assignment in handlers. +Phase 5: OpenAPI alignment (ROSAENG-61805) + openapi-gen → openapi-merge pipeline generates typed + schemas for CRD types and merges them into openapi.yaml. +Phase 6: CI verification (ROSAENG-61806) + Wire codegen checks into CI. +``` + +## Makefile targets + +```bash +# Build +make build-api-codegen # Build all 8 generator binaries +make test-api-codegen # Run tests +make coverage-api-codegen # Generate coverage report + +# Codegen +make codegen-passthrough # Run passthrough-gen (typed passthrough structs) +make codegen-registry # Run marker-scanner (field_metadata.go/.json) +make codegen-openapi # Run openapi-gen + openapi-merge (update openapi.yaml) +make codegen-verify # Verify codegen packages compile + +# Verification +make verify-openapi # Verify openapi.yaml matches generated schemas + +# API docs +make swagger-ui-serve # Serve Swagger UI locally on port 8080 (requires Python 3) +make swagger-ui-open # Open Swagger UI in browser +``` + +## Key markers + +| Marker | Purpose | +|--------|---------| +| `+hyperfleet:write-mode=mutable` | Field can be set on create and updated | +| `+hyperfleet:write-mode=immutable` | Field can be set on create but not changed | +| `+hyperfleet:write-mode=service-set` | Platform-managed field, rejected if customer sets it | +| `+k8s:openapi-gen=false` | Exclude field from generated OpenAPI schemas | +| `+openshift:enable:FeatureGate=X` | Field requires feature gate X to be enabled | + +## How openapi-merge works + +1. `openapi-gen` scans `hyperfleet-operator/api/v1alpha1` and emits Swagger 2.0 JSON with definitions for all visible types (fields marked `+k8s:openapi-gen=false` are excluded). +2. `openapi-merge` reads that JSON and: + - Converts `$ref` paths from `#/definitions/X` to `#/components/schemas/X` + - Strips `+hyperfleet:*` and `+kubebuilder:*` marker lines from descriptions + - Links `ClusterSpec.hostedCluster` to `HostedClusterSpecPassthrough` + - Inlines self-referential `$ref`s (e.g. `NodePoolSpec.nodePool`) + - Replaces 6 schema entries in `platform-api/openapi/openapi.yaml`: `ClusterSpec`, `NodePoolSpec`, `ClusterConfiguration`, `KubeletConfig`, `MachineConfigSpec`, `HostedClusterSpecPassthrough` +3. The pipeline is idempotent — running `make codegen-openapi` twice produces no diff. + +## OpenAPI hybrid model + +The `platform-api/openapi/openapi.yaml` uses a hybrid approach: +- **Codegen-owned**: CRD spec schemas (ClusterSpec, NodePoolSpec, sub-types) — generated from Go types with markers +- **Human-owned**: Routes, request/response envelopes, non-CRD schemas (Error, Cluster, NodePool wrapper types, etc.) diff --git a/hack/api-codegen/cmd/conversion-gen/main.go b/hack/api-codegen/cmd/conversion-gen/main.go new file mode 100644 index 00000000..a80991cd --- /dev/null +++ b/hack/api-codegen/cmd/conversion-gen/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/conversion" +) + +func main() { + var ( + apiVersion string + crdPackage string + inputDirs string + outputDir string + ) + + flag.StringVar(&apiVersion, "api-version", "v1alpha1", "API version to generate for") + flag.StringVar(&crdPackage, "crd-package", "", "Import path to CRD types (required)") + flag.StringVar(&inputDirs, "input-dirs", "", "Comma-separated list of directories containing CRD source files (required)") + flag.StringVar(&outputDir, "output-dir", "", "Output directory for generated code (required)") + flag.Parse() + + if crdPackage == "" || inputDirs == "" || outputDir == "" { + flag.Usage() + fmt.Fprintf(os.Stderr, "\nError: Missing required flags\n\n") + fmt.Fprintf(os.Stderr, "Example usage:\n") + fmt.Fprintf(os.Stderr, " %s \\\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " --api-version=v1alpha1 \\\n") + fmt.Fprintf(os.Stderr, " --crd-package=github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/api/v1alpha1 \\\n") + fmt.Fprintf(os.Stderr, " --input-dirs=./api/v1alpha1 \\\n") + fmt.Fprintf(os.Stderr, " --output-dir=./pkg/conversion/v1alpha1\n") + os.Exit(1) + } + + // Split input directories + dirs := strings.Split(inputDirs, ",") + for i, dir := range dirs { + // Convert to absolute path + absDir, err := filepath.Abs(dir) + if err != nil { + log.Fatalf("Failed to resolve directory %s: %v", dir, err) + } + dirs[i] = absDir + } + + // Create generator + gen := conversion.NewGenerator(apiVersion, crdPackage, dirs, outputDir) + + log.Printf("Conversion code generator") + log.Printf(" API Version: %s", apiVersion) + log.Printf(" CRD Package: %s", crdPackage) + log.Printf(" Input Dirs: %s", strings.Join(dirs, ", ")) + log.Printf(" Output Dir: %s", outputDir) + log.Println() + + // Generate + if err := gen.Generate(); err != nil { + log.Fatalf("Generation failed: %v", err) + } + + log.Println("✓ Successfully generated:") + log.Println(" - REST types (rest/)") + log.Println(" - ServiceSetFields (../types.go)") + log.Println(" - Conversion functions (cluster.go, nodepool.go)") +} diff --git a/hack/api-codegen/cmd/crd-variants/main.go b/hack/api-codegen/cmd/crd-variants/main.go new file mode 100644 index 00000000..a18118d2 --- /dev/null +++ b/hack/api-codegen/cmd/crd-variants/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/featuregate" +) + +func main() { + var ( + inputFile = flag.String("input", "", "Input CRD YAML file") + outputDir = flag.String("output-dir", "config/crd/variants", "Output directory for CRD variants") + baseName = flag.String("base-name", "", "Base name for output files (e.g., 'cluster' produces cluster_default.yaml)") + featureSet = flag.String("feature-set", "", "Generate only one feature set variant (default, techpreview, devpreview)") + ) + + flag.Parse() + + if *inputFile == "" { + fmt.Fprintln(os.Stderr, "Error: --input is required") + flag.Usage() + os.Exit(1) + } + + if *baseName == "" { + fmt.Fprintln(os.Stderr, "Error: --base-name is required") + flag.Usage() + os.Exit(1) + } + + // Create output directory + if err := os.MkdirAll(*outputDir, 0755); err != nil { + log.Fatalf("Creating output directory: %v", err) + } + + g := featuregate.NewCRDVariantGenerator() + + if *featureSet != "" { + // Generate single variant + var fs featuregate.FeatureSet + switch *featureSet { + case "default": + fs = featuregate.Default + case "techpreview": + fs = featuregate.TechPreviewNoUpgrade + case "devpreview": + fs = featuregate.DevPreviewNoUpgrade + default: + log.Fatalf("Invalid feature set: %s (must be default, techpreview, or devpreview)", *featureSet) + } + + outputPath := fmt.Sprintf("%s/%s_%s.yaml", *outputDir, *baseName, *featureSet) + fmt.Printf("Generating %s variant: %s\n", *featureSet, outputPath) + + if err := g.GenerateVariant(*inputFile, outputPath, fs); err != nil { + log.Fatalf("Generating variant: %v", err) + } + + fmt.Printf("✓ Generated %s variant\n", *featureSet) + } else { + // Generate all variants + fmt.Printf("Generating all CRD variants from %s to %s/\n", *inputFile, *outputDir) + + if err := g.GenerateAllVariants(*inputFile, *outputDir, *baseName); err != nil { + log.Fatalf("Generating variants: %v", err) + } + + fmt.Printf("✓ Generated default variant: %s/%s_default.yaml\n", *outputDir, *baseName) + fmt.Printf("✓ Generated techpreview variant: %s/%s_techpreview.yaml\n", *outputDir, *baseName) + fmt.Printf("✓ Generated devpreview variant: %s/%s_devpreview.yaml\n", *outputDir, *baseName) + } +} diff --git a/hack/api-codegen/cmd/featuregate-info/main.go b/hack/api-codegen/cmd/featuregate-info/main.go new file mode 100644 index 00000000..c10aed31 --- /dev/null +++ b/hack/api-codegen/cmd/featuregate-info/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "os" + "sort" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/featuregate" +) + +func main() { + fmt.Println("=== HyperFleet Feature Gate Registry ===") + fmt.Println() + + // List all feature gates + fmt.Println("Registered Feature Gates:") + fmt.Println() + + gates := make([]string, 0, len(featuregate.HyperFleetFeatureGates)) + for gate := range featuregate.HyperFleetFeatureGates { + gates = append(gates, gate) + } + sort.Strings(gates) + + for _, gate := range gates { + info := featuregate.HyperFleetFeatureGates[gate] + fmt.Printf(" %-30s Stage: %-12s %s\n", gate, info.Stage, info.Description) + } + + fmt.Println() + fmt.Println("=== Feature Set Field Summary ===") + fmt.Println() + + featureSets := []featuregate.FeatureSet{ + featuregate.Default, + featuregate.TechPreviewNoUpgrade, + featuregate.DevPreviewNoUpgrade, + } + + for _, fs := range featureSets { + fields := featuregate.FieldsForFeatureSet(fs) + gates := featuregate.GatesForFeatureSet(fs) + + fmt.Printf("%s:\n", fs) + fmt.Printf(" Total visible fields: %d\n", len(fields)) + fmt.Printf(" Enabled gates: %v\n", gates) + fmt.Println() + } + + os.Exit(0) +} diff --git a/hack/api-codegen/cmd/marker-scanner/main.go b/hack/api-codegen/cmd/marker-scanner/main.go new file mode 100644 index 00000000..8d59be3f --- /dev/null +++ b/hack/api-codegen/cmd/marker-scanner/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/markers" +) + +func main() { + var ( + inputDirs string + outputFile string + validate bool + verbose bool + ) + + flag.StringVar(&inputDirs, "input-dirs", "", "Comma-separated list of directories to scan (required)") + flag.StringVar(&outputFile, "output-file", "", "Output file for generated registry (required)") + flag.BoolVar(&validate, "validate", true, "Validate that all visible fields have write-mode markers") + flag.BoolVar(&verbose, "verbose", false, "Show detailed table of fields and their markers") + flag.Parse() + + if inputDirs == "" || outputFile == "" { + flag.Usage() + os.Exit(1) + } + + dirs := strings.Split(inputDirs, ",") + for i := range dirs { + dirs[i] = strings.TrimSpace(dirs[i]) + } + + // Create scanner and scan directories + scanner := markers.NewScanner(dirs) + + log.Printf("Scanning directories: %v", dirs) + if err := scanner.Scan(); err != nil { + log.Fatalf("Error scanning: %v", err) + } + + log.Printf("Found %d fields with markers", len(scanner.Registry)) + + // Show scanned fields if verbose + if verbose { + fmt.Println() + fmt.Println("=== Scanned Fields ===") + fmt.Println() + printRegistryTable(scanner.Registry) + printRegistryStats(scanner.Registry) + fmt.Println() + } + + // Validate if requested + if validate { + if err := scanner.Registry.Validate(); err != nil { + log.Fatalf("Validation failed: %v", err) + } + log.Println("Validation passed") + } + + // Generate registry file + log.Printf("Generating registry: %s", outputFile) + if err := scanner.Generate(outputFile); err != nil { + log.Fatalf("Error generating registry: %v", err) + } + + fmt.Printf("Successfully generated field registry at %s\n", outputFile) + + // Also generate JSON file for use by other tools + jsonFile := strings.TrimSuffix(outputFile, ".go") + ".json" + log.Printf("Generating JSON registry: %s", jsonFile) + if err := scanner.GenerateJSON(jsonFile); err != nil { + log.Fatalf("Error generating JSON registry: %v", err) + } + + fmt.Printf("Successfully generated JSON registry at %s\n", jsonFile) + + // Show what was generated if verbose + if verbose { + fmt.Println() + fmt.Println("=== Generated Registry Contents ===") + fmt.Printf("File: %s\n", outputFile) + fmt.Printf("Package: registry\n") + fmt.Printf("Exported: FieldRegistry map[string]FieldMeta\n") + fmt.Println() + fmt.Println("The generated file contains:") + printRegistryTable(scanner.Registry) + printRegistryStats(scanner.Registry) + } +} + +// printRegistryTable displays the field registry as a formatted table +func printRegistryTable(registry markers.FieldRegistry) { + // Sort field paths + var paths []string + for path := range registry { + paths = append(paths, path) + } + sort.Strings(paths) + + // Create table writer + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "FIELD PATH\tWRITE MODE\tFEATURE GATE\tHIDDEN") + _, _ = fmt.Fprintln(w, "----------\t----------\t------------\t------") + + // Print each field + for _, path := range paths { + meta := registry[path] + + writeMode := string(meta.WriteMode) + if writeMode == "" { + writeMode = "-" + } + + featureGate := meta.FeatureGate + if featureGate == "" { + featureGate = "-" + } + + hidden := "no" + if meta.Hidden { + hidden = "yes" + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", path, writeMode, featureGate, hidden) + } + + _ = w.Flush() +} + +// printRegistryStats displays summary statistics about the registry +func printRegistryStats(registry markers.FieldRegistry) { + var ( + mutable int + immutable int + serviceSet int + hidden int + gated int + ) + + for _, meta := range registry { + switch meta.WriteMode { + case markers.Mutable: + mutable++ + case markers.Immutable: + immutable++ + case markers.ServiceSet: + serviceSet++ + } + if meta.Hidden { + hidden++ + } + if meta.FeatureGate != "" { + gated++ + } + } + + fmt.Println() + fmt.Printf("Summary: %d total fields\n", len(registry)) + fmt.Printf(" Write Modes: %d mutable, %d immutable, %d service-set\n", mutable, immutable, serviceSet) + fmt.Printf(" Visibility: %d visible, %d hidden\n", len(registry)-hidden, hidden) + fmt.Printf(" Gating: %d gated, %d ungated\n", gated, len(registry)-gated) +} diff --git a/hack/api-codegen/cmd/openapi-gen/main.go b/hack/api-codegen/cmd/openapi-gen/main.go new file mode 100644 index 00000000..531ee26b --- /dev/null +++ b/hack/api-codegen/cmd/openapi-gen/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/openapi" +) + +func main() { + var ( + inputDirs string + outputFile string + title string + version string + ) + + flag.StringVar(&inputDirs, "input-dirs", "", "Comma-separated list of directories to scan for Go types (required)") + flag.StringVar(&outputFile, "output-file", "", "Output file for OpenAPI schema (required)") + flag.StringVar(&title, "title", "HyperFleet API", "API title") + flag.StringVar(&version, "version", "v1alpha1", "API version") + flag.Parse() + + if outputFile == "" { + flag.Usage() + os.Exit(1) + } + + // Parse input directories + var dirs []string + if inputDirs != "" { + for _, dir := range strings.Split(inputDirs, ",") { + dirs = append(dirs, strings.TrimSpace(dir)) + } + } + + // Create generator + gen := openapi.NewGenerator(dirs, outputFile) + gen.Title = title + gen.Version = version + + log.Printf("Generating OpenAPI schema: %s v%s", title, version) + if len(dirs) > 0 { + log.Printf("Scanning directories: %v", dirs) + } else { + log.Println("No input directories specified - generating minimal POC schema") + } + + if err := gen.Generate(); err != nil { + log.Fatalf("Failed to generate OpenAPI schema: %v", err) + } + + fmt.Printf("Successfully generated OpenAPI schema at %s\n", outputFile) + if len(dirs) > 0 { + fmt.Println("Schema includes all types with visible fields (+k8s:openapi-gen=false fields excluded)") + } +} diff --git a/hack/api-codegen/cmd/openapi-merge/main.go b/hack/api-codegen/cmd/openapi-merge/main.go new file mode 100644 index 00000000..61c52c03 --- /dev/null +++ b/hack/api-codegen/cmd/openapi-merge/main.go @@ -0,0 +1,314 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +var mergeTypes = map[string]bool{ + "ClusterSpec": true, + "NodePoolSpec": true, + "ClusterConfiguration": true, + "KubeletConfig": true, + "MachineConfigSpec": true, + "HostedClusterSpecPassthrough": true, +} + +func main() { + generatedPath := flag.String("generated", "", "Path to generated Swagger 2.0 JSON definitions") + specPath := flag.String("spec", "", "Path to openapi.yaml to update") + flag.Parse() + + if *generatedPath == "" || *specPath == "" { + log.Fatal("--generated and --spec are required") + } + + genData, err := os.ReadFile(*generatedPath) + if err != nil { + log.Fatalf("reading generated file: %v", err) + } + + var swagger struct { + Definitions map[string]json.RawMessage `json:"definitions"` + } + if err := json.Unmarshal(genData, &swagger); err != nil { + log.Fatalf("parsing generated JSON: %v", err) + } + + schemas := make(map[string]*yaml.Node) + for name, raw := range swagger.Definitions { + if !mergeTypes[name] { + continue + } + node, err := swaggerDefToOAS3(name, raw) + if err != nil { + log.Fatalf("converting %s: %v", name, err) + } + schemas[name] = node + } + + var missing []string + for name := range mergeTypes { + if _, ok := schemas[name]; !ok { + missing = append(missing, name) + } + } + if len(missing) > 0 { + sort.Strings(missing) + log.Fatalf("merge types not found in generated definitions: %s", strings.Join(missing, ", ")) + } + + specData, err := os.ReadFile(*specPath) + if err != nil { + log.Fatalf("reading spec: %v", err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(specData, &doc); err != nil { + log.Fatalf("parsing spec YAML: %v", err) + } + + schemasNode := findSchemasNode(&doc) + if schemasNode == nil { + log.Fatal("could not find components.schemas in spec") + } + + replaced := 0 + for i := 0; i < len(schemasNode.Content)-1; i += 2 { + keyNode := schemasNode.Content[i] + if mergeTypes[keyNode.Value] { + if node, ok := schemas[keyNode.Value]; ok { + schemasNode.Content[i+1] = node + replaced++ + } + } + } + + schemaNames := make([]string, 0, len(schemas)) + for name := range schemas { + schemaNames = append(schemaNames, name) + } + sort.Strings(schemaNames) + + for _, name := range schemaNames { + found := false + for i := 0; i < len(schemasNode.Content)-1; i += 2 { + if schemasNode.Content[i].Value == name { + found = true + break + } + } + if !found { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name} + schemasNode.Content = append(schemasNode.Content, keyNode, schemas[name]) + replaced++ + } + } + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&doc); err != nil { + log.Fatalf("marshaling updated spec: %v", err) + } + enc.Close() + + if err := os.WriteFile(*specPath, buf.Bytes(), 0644); err != nil { + log.Fatalf("writing spec: %v", err) + } + + fmt.Printf("openapi-merge: replaced/added %d schemas in %s\n", replaced, *specPath) +} + +func findSchemasNode(doc *yaml.Node) *yaml.Node { + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + doc = doc.Content[0] + } + if doc.Kind != yaml.MappingNode { + return nil + } + for i := 0; i < len(doc.Content)-1; i += 2 { + if doc.Content[i].Value == "components" { + comp := doc.Content[i+1] + if comp.Kind != yaml.MappingNode { + return nil + } + for j := 0; j < len(comp.Content)-1; j += 2 { + if comp.Content[j].Value == "schemas" { + return comp.Content[j+1] + } + } + } + } + return nil +} + +var markerRe = regexp.MustCompile(`(?m)^\+[a-zA-Z].*$`) + +var passthroughRefs = map[string]map[string]string{ + "ClusterSpec": {"hostedCluster": "HostedClusterSpecPassthrough"}, +} + +func swaggerDefToOAS3(typeName string, raw json.RawMessage) (*yaml.Node, error) { + var def map[string]interface{} + if err := json.Unmarshal(raw, &def); err != nil { + return nil, err + } + + cleanDescription(def) + inlineSelfRefs(typeName, def) + linkPassthroughRefs(typeName, def) + convertRefs(def) + + node := toYAMLNode(def) + return node, nil +} + +func linkPassthroughRefs(typeName string, m map[string]interface{}) { + fieldMap, ok := passthroughRefs[typeName] + if !ok { + return + } + props, ok := m["properties"].(map[string]interface{}) + if !ok { + return + } + for field, targetType := range fieldMap { + pm, ok := props[field].(map[string]interface{}) + if !ok { + continue + } + if _, hasRef := pm["$ref"]; hasRef { + continue + } + pm["$ref"] = "#/definitions/" + targetType + delete(pm, "type") + delete(pm, "additionalProperties") + } +} + +func inlineSelfRefs(typeName string, m map[string]interface{}) { + props, ok := m["properties"].(map[string]interface{}) + if !ok { + return + } + selfRef := "#/definitions/" + typeName + for field, v := range props { + pm, ok := v.(map[string]interface{}) + if !ok { + continue + } + if ref, ok := pm["$ref"].(string); ok && ref == selfRef { + delete(pm, "$ref") + pm["type"] = "object" + pm["additionalProperties"] = true + log.Printf("inlined self-ref %s.%s → type: object", typeName, field) + } + } +} + +func toYAMLNode(v interface{}) *yaml.Node { + switch val := v.(type) { + case map[string]interface{}: + node := &yaml.Node{Kind: yaml.MappingNode} + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: k} + valNode := toYAMLNode(val[k]) + node.Content = append(node.Content, keyNode, valNode) + } + return node + case []interface{}: + node := &yaml.Node{Kind: yaml.SequenceNode} + for _, item := range val { + node.Content = append(node.Content, toYAMLNode(item)) + } + return node + case string: + return &yaml.Node{Kind: yaml.ScalarNode, Value: val} + case float64: + if val == float64(int64(val)) { + return &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("%d", int64(val)), Tag: "!!int"} + } + return &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("%g", val)} + case bool: + return &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("%t", val), Tag: "!!bool"} + case nil: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null"} + default: + return &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("%v", val)} + } +} + +func cleanDescription(m map[string]interface{}) { + if desc, ok := m["description"].(string); ok { + lines := strings.Split(desc, "\n") + var cleaned []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if markerRe.MatchString(trimmed) { + continue + } + cleaned = append(cleaned, line) + } + result := strings.TrimSpace(strings.Join(cleaned, "\n")) + if result == "" { + delete(m, "description") + } else { + m["description"] = result + } + } + + if props, ok := m["properties"].(map[string]interface{}); ok { + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if pm, ok := props[k].(map[string]interface{}); ok { + cleanDescription(pm) + } + } + } + + if items, ok := m["items"].(map[string]interface{}); ok { + cleanDescription(items) + } + + if addl, ok := m["additionalProperties"].(map[string]interface{}); ok { + cleanDescription(addl) + } +} + +func convertRefs(m map[string]interface{}) { + if ref, ok := m["$ref"].(string); ok { + m["$ref"] = strings.Replace(ref, "#/definitions/", "#/components/schemas/", 1) + } + + for _, v := range m { + switch val := v.(type) { + case map[string]interface{}: + convertRefs(val) + case []interface{}: + for _, item := range val { + if im, ok := item.(map[string]interface{}); ok { + convertRefs(im) + } + } + } + } +} diff --git a/hack/api-codegen/cmd/passthrough-gen/main.go b/hack/api-codegen/cmd/passthrough-gen/main.go new file mode 100644 index 00000000..9d437dc5 --- /dev/null +++ b/hack/api-codegen/cmd/passthrough-gen/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/markers" + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/passthrough" +) + +func main() { + var ( + sourceDir string + importPath string + outputDir string + typeNames string + registryFile string + packageName string + fieldPrefix string + ) + + flag.StringVar(&sourceDir, "source-dir", "", "Directory containing source Go files (use this OR -import-path)") + flag.StringVar(&importPath, "import-path", "", "Go import path to resolve via go.mod (use this OR -source-dir)") + flag.StringVar(&outputDir, "output-dir", "", "Directory for generated output (required)") + flag.StringVar(&typeNames, "types", "", "Comma-separated list of type names to generate (required)") + flag.StringVar(®istryFile, "registry", "", "Path to field metadata registry JSON (required)") + flag.StringVar(&packageName, "package", "v1alpha1", "Package name for generated code") + flag.StringVar(&fieldPrefix, "field-prefix", "", "Dotted path prefix for registry lookups (e.g., spec.hostedCluster)") + flag.Parse() + + // Validate flags + if outputDir == "" || typeNames == "" || registryFile == "" { + flag.Usage() + os.Exit(1) + } + + if sourceDir == "" && importPath == "" { + log.Fatalf("Either -source-dir or -import-path must be specified") + } + + if sourceDir != "" && importPath != "" { + log.Fatalf("Cannot specify both -source-dir and -import-path") + } + + // Parse type names + types := strings.Split(typeNames, ",") + for i := range types { + types[i] = strings.TrimSpace(types[i]) + } + + // Load field metadata registry + log.Printf("Loading field registry from: %s", registryFile) + registry, err := markers.LoadRegistryFromJSON(registryFile) + if err != nil { + log.Fatalf("Failed to load registry: %v", err) + } + log.Printf("Loaded %d field markers from registry", len(registry)) + + // Create generator + var gen *passthrough.Generator + + if importPath != "" { + log.Printf("Resolving import path: %s", importPath) + gen, err = passthrough.NewGeneratorFromImportPath(importPath, types, registry) + if err != nil { + log.Fatalf("Failed to resolve import path: %v", err) + } + log.Printf("Resolved to directory: %s", gen.SourceDir) + } else { + gen = passthrough.NewGenerator(sourceDir, types, registry) + } + + gen.OutputPackage = packageName + gen.FieldPrefix = fieldPrefix + + // Load source files + log.Printf("Loading source files from: %s", gen.SourceDir) + if err := gen.LoadSourceFiles(gen.SourceDir); err != nil { + log.Fatalf("Failed to load source files: %v", err) + } + + log.Printf("Loaded %d source files", len(gen.ParsedFiles())) + + // Generate passthrough types + log.Printf("Generating passthrough types: %v", types) + if err := gen.Generate(outputDir); err != nil { + log.Fatalf("Failed to generate: %v", err) + } + + fmt.Printf("Successfully generated passthrough types in %s\n", outputDir) +} diff --git a/hack/api-codegen/cmd/verify-configuration/main.go b/hack/api-codegen/cmd/verify-configuration/main.go new file mode 100644 index 00000000..d52be907 --- /dev/null +++ b/hack/api-codegen/cmd/verify-configuration/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "strings" +) + +// verify-configuration ensures all fields in configuration.go have required markers +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: verify-configuration ") + os.Exit(1) + } + + filePath := os.Args[1] + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing file: %v\n", err) + os.Exit(1) + } + + var errors []string + + // Visit all struct type declarations + ast.Inspect(node, func(n ast.Node) bool { + typeSpec, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return true + } + + // Only check config-related structs + typeName := typeSpec.Name.Name + if !isConfigType(typeName) { + return true + } + + // Check each field in the struct + for _, field := range structType.Fields.List { + if field.Names == nil { + // Embedded field, skip + continue + } + + fieldName := field.Names[0].Name + if !field.Names[0].IsExported() { + // Unexported field, skip + continue + } + + // Check if field has markers (in Doc or Comment) + hasWriteMode := false + hasVisibility := false + + // Check Doc comments (above the field) + if field.Doc != nil { + for _, comment := range field.Doc.List { + text := comment.Text + if strings.Contains(text, "+hyperfleet:write-mode=") { + hasWriteMode = true + } + if strings.Contains(text, "+k8s:openapi-gen=") { + hasVisibility = true + } + } + } + + // Check inline comments (after the field) + if field.Comment != nil { + for _, comment := range field.Comment.List { + text := comment.Text + if strings.Contains(text, "+hyperfleet:write-mode=") { + hasWriteMode = true + } + if strings.Contains(text, "+k8s:openapi-gen=") { + hasVisibility = true + } + } + } + + // Fields should have write-mode marker + if !hasWriteMode { + errors = append(errors, fmt.Sprintf("%s.%s: missing +hyperfleet:write-mode marker", typeName, fieldName)) + } + + // Note: Visibility markers are optional + // - No marker = visible (default, standard Kubernetes convention) + // - +k8s:openapi-gen=false = hidden (explicit) + // We only enforce write-mode markers, not visibility markers + _ = hasVisibility // Acknowledged - used for future enforcement if needed + } + + return true + }) + + if len(errors) > 0 { + fmt.Println("Configuration verification failed:") + for _, err := range errors { + fmt.Printf(" ❌ %s\n", err) + } + fmt.Println("\nAll fields in configuration types must have +hyperfleet:write-mode markers.") + fmt.Println("Add one of: mutable, immutable, service-set") + os.Exit(1) + } + + fmt.Println("✅ Configuration verification passed") +} + +func isConfigType(name string) bool { + // Skip support types that are never exposed directly + supportTypes := []string{ + "SystemdUnit", + "SystemdDropin", + "FileSpec", + } + for _, t := range supportTypes { + if name == t { + return false + } + } + + // Check if this is a configuration-related type that needs markers + suffixes := []string{ + "Configuration", + "Config", + "Spec", + } + + for _, suffix := range suffixes { + if strings.HasSuffix(name, suffix) { + return true + } + } + + return false +} diff --git a/hack/api-codegen/go.mod b/hack/api-codegen/go.mod new file mode 100644 index 00000000..c7f1d6b5 --- /dev/null +++ b/hack/api-codegen/go.mod @@ -0,0 +1,48 @@ +module github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen + +go 1.26.3 + +require ( + github.com/openshift/hypershift/api v0.0.0-20260625052409-9acec4759a16 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 +) + +require ( + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/openshift/api v0.0.0-20260416105050-3c6b218b8a80 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.36.0 // indirect + k8s.io/apimachinery v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.1 // indirect +) diff --git a/hack/api-codegen/go.sum b/hack/api-codegen/go.sum new file mode 100644 index 00000000..7da2bba7 --- /dev/null +++ b/hack/api-codegen/go.sum @@ -0,0 +1,112 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/openshift/api v0.0.0-20260416105050-3c6b218b8a80 h1:r0S/yoZAI0iWo1JvoIijaIgWGWf/izg4WiV7Wrtz16k= +github.com/openshift/api v0.0.0-20260416105050-3c6b218b8a80/go.mod h1:pyVjK0nZ4sRs4fuQVQ4rubsJdahI1PB94LnQ8sGdvxo= +github.com/openshift/hypershift/api v0.0.0-20260625052409-9acec4759a16 h1:QDSh3vkKYq7Fn9utYGlAJadkTdyaRl9IY7Cr6cNDAow= +github.com/openshift/hypershift/api v0.0.0-20260625052409-9acec4759a16/go.mod h1:Z3lkj5pFqY+KTl3Do9gXdEZdKWLnkUTSDShLD1HE0CM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= +k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 h1:CVjOUCTXINUThEmDs25FNSna0+vnGSoTleN+wiJu6hE= +k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0/go.mod h1:rcZ+P5cEvHQB+m154WBOatIGBgOEPjzmLkXjkHfg3ms= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.1 h1:AkER7js0XVWi/F/V2Iwl5N7O/B9VP2JyrOMmHPdco+g= +sigs.k8s.io/structured-merge-diff/v6 v6.4.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/api-codegen/pkg/conversion/generator.go b/hack/api-codegen/pkg/conversion/generator.go new file mode 100644 index 00000000..e2c4dbe3 --- /dev/null +++ b/hack/api-codegen/pkg/conversion/generator.go @@ -0,0 +1,1077 @@ +package conversion + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/registry" +) + +// Generator generates REST types and conversion functions from CRD types +type Generator struct { + APIVersion string // e.g., "v1alpha1" + CRDPackage string // Import path to CRD types + OutputDir string // Output directory for generated code + OutputPackage string // Import path for the output package (derived from OutputDir if empty) + InputDirs []string // Directories containing CRD source files + + // Internal state + knownTypes map[string]bool // Set of all type names + typeInfos map[string]*typeInfo // Type name -> type information + emittedHelpers map[string]bool // Tracks emitted project/unproject helpers to avoid duplicates +} + +// typeInfo holds parsed information about a Go type +type typeInfo struct { + Name string + StructType *ast.StructType + Doc *ast.CommentGroup + Fields []*fieldInfo +} + +// fieldInfo holds information about a struct field +type fieldInfo struct { + GoName string // Go field name (e.g., "DisplayName") + JSONName string // JSON tag name (e.g., "displayName") + GoType string // Go type as string (e.g., "string", "*bool") + FieldPath string // Registry path (e.g., "spec.displayName") + Field *ast.Field // Original AST field + Doc *ast.CommentGroup + Hidden bool // From registry + WriteMode registry.WriteMode +} + +// NewGenerator creates a new conversion generator +func NewGenerator(apiVersion, crdPackage string, inputDirs []string, outputDir string) *Generator { + return &Generator{ + APIVersion: apiVersion, + CRDPackage: crdPackage, + InputDirs: inputDirs, + OutputDir: outputDir, + knownTypes: make(map[string]bool), + typeInfos: make(map[string]*typeInfo), + emittedHelpers: make(map[string]bool), + } +} + +// Generate runs all three generation phases +func (g *Generator) Generate() error { + // Parse CRD types first + if err := g.parseTypes(); err != nil { + return fmt.Errorf("parsing types: %w", err) + } + + // Phase 1: Generate REST types (filter hidden fields) + if err := g.generateRESTTypes(); err != nil { + return fmt.Errorf("generating REST types: %w", err) + } + + // Phase 2: Generate ServiceSetFields + if err := g.generateServiceSetFields(); err != nil { + return fmt.Errorf("generating ServiceSetFields: %w", err) + } + + // Phase 3: Generate conversion functions + if err := g.generateConversionFunctions(); err != nil { + return fmt.Errorf("generating conversion functions: %w", err) + } + + return nil +} + +// parseTypes scans input directories and parses all CRD types +func (g *Generator) parseTypes() error { + for _, dir := range g.InputDirs { + fset := token.NewFileSet() + + // Parse all Go files in directory + //nolint:staticcheck // ParseDir is sufficient for our use case + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + name := fi.Name() + // Skip test files and generated files + return !strings.HasSuffix(name, "_test.go") && + !strings.HasPrefix(name, "zz_generated") + }, parser.ParseComments) + + if err != nil { + return fmt.Errorf("parsing directory %s: %w", dir, err) + } + + // Collect all types + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok || !typeSpec.Name.IsExported() { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + typeName := typeSpec.Name.Name + g.knownTypes[typeName] = true + + // Create type info + ti := &typeInfo{ + Name: typeName, + StructType: structType, + Doc: genDecl.Doc, + Fields: []*fieldInfo{}, + } + + // Parse fields + for _, field := range structType.Fields.List { + // Skip embedded fields + if len(field.Names) == 0 { + continue + } + + for _, name := range field.Names { + // Skip unexported fields + if !name.IsExported() { + continue + } + + fi := g.parseField(typeName, field, name) + if fi != nil { + ti.Fields = append(ti.Fields, fi) + } + } + } + + g.typeInfos[typeName] = ti + } + } + } + } + } + + return nil +} + +// parseField parses a single struct field +func (g *Generator) parseField(typeName string, field *ast.Field, name *ast.Ident) *fieldInfo { + goName := name.Name + jsonName := g.extractJSONTag(field) + if jsonName == "" || jsonName == "-" { + return nil // Skip fields without JSON tags + } + + // Build field path for registry lookup + fieldPath := g.buildFieldPath(typeName, jsonName) + + // Lookup in registry + meta, exists := registry.FieldRegistry[fieldPath] + + fi := &fieldInfo{ + GoName: goName, + JSONName: jsonName, + GoType: g.exprToString(field.Type), + Field: field, + Doc: field.Doc, + } + + if exists { + fi.FieldPath = meta.FieldPath + fi.Hidden = meta.Hidden + fi.WriteMode = meta.WriteMode + } + + return fi +} + +// buildFieldPath constructs the registry path for a field +func (g *Generator) buildFieldPath(typeName, jsonName string) string { + // Map type names to registry prefixes + switch { + case strings.HasSuffix(typeName, "Spec"): + return "spec." + jsonName + case strings.HasSuffix(typeName, "Status"): + return "status." + jsonName + case strings.Contains(typeName, "Passthrough"): + // For passthrough types, need to determine prefix + // e.g., HostedClusterSpecPassthrough -> "spec.hostedCluster." + if strings.HasPrefix(typeName, "HostedCluster") { + return "spec.hostedCluster." + jsonName + } + if strings.HasPrefix(typeName, "NodePool") { + return "spec.nodePool." + jsonName + } + return jsonName + default: + return jsonName + } +} + +// extractJSONTag extracts the JSON tag from a field +func (g *Generator) extractJSONTag(field *ast.Field) string { + if field.Tag == nil { + return "" + } + + tag := strings.Trim(field.Tag.Value, "`") + for _, part := range strings.Fields(tag) { + if strings.HasPrefix(part, "json:") { + jsonTag := strings.Trim(strings.TrimPrefix(part, "json:"), "\"") + // Strip options (e.g., "name,omitempty" -> "name") + if idx := strings.Index(jsonTag, ","); idx >= 0 { + return jsonTag[:idx] + } + return jsonTag + } + } + + return "" +} + +// exprToString converts an AST expression to a string +func (g *Generator) exprToString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return "*" + g.exprToString(t.X) + case *ast.ArrayType: + return "[]" + g.exprToString(t.Elt) + case *ast.MapType: + return "map[" + g.exprToString(t.Key) + "]" + g.exprToString(t.Value) + case *ast.SelectorExpr: + return g.exprToString(t.X) + "." + t.Sel.Name + case *ast.InterfaceType, *ast.FuncType, *ast.Ellipsis, *ast.IndexExpr, *ast.IndexListExpr: + var buf bytes.Buffer + if err := printer.Fprint(&buf, token.NewFileSet(), expr); err != nil { + return "interface{}" + } + return buf.String() + default: + return "interface{}" + } +} + +// typeQualifiers maps unqualified Go type names to the import alias they +// require when referenced outside their declaring package. +var typeQualifiers = map[string]string{ + "ClusterConfiguration": "v1alpha1", + "KubeletConfig": "v1alpha1", + "MachineConfigSpec": "v1alpha1", + "APIServerNetworkConfiguration": "v1alpha1", + "ClusterAuthentication": "v1alpha1", + "FeatureGateConfiguration": "v1alpha1", + "ImageConfiguration": "v1alpha1", + "IngressConfiguration": "v1alpha1", + "NetworkConfiguration": "v1alpha1", + "OAuthConfiguration": "v1alpha1", + "SchedulerConfiguration": "v1alpha1", + "ProxyConfiguration": "v1alpha1", + "AutoNode": "hypershiftv1beta1", + "Release": "hypershiftv1beta1", + "PlatformSpec": "hypershiftv1beta1", + "DNSSpec": "hypershiftv1beta1", + "ClusterNetworking": "hypershiftv1beta1", + "ClusterAutoscaling": "hypershiftv1beta1", + "EtcdSpec": "hypershiftv1beta1", + "ServicePublishingStrategyMapping": "hypershiftv1beta1", + "ImageContentSource": "hypershiftv1beta1", + "SecretEncryptionSpec": "hypershiftv1beta1", + "OLMCatalogPlacement": "hypershiftv1beta1", + "Capabilities": "hypershiftv1beta1", + "OperatorConfiguration": "hypershiftv1beta1", + "NodePoolPlatform": "hypershiftv1beta1", + "NodePoolManagement": "hypershiftv1beta1", + "NodePoolAutoScaling": "hypershiftv1beta1", + "Taint": "hypershiftv1beta1", + "AvailabilityPolicy": "hypershiftv1beta1", +} + +// detectTypeImport returns the import alias required for a Go type string, +// checking both already-qualified prefixes and unqualified names via typeQualifiers. +func detectTypeImport(goType string) string { + base := strings.TrimPrefix(goType, "*") + base = strings.TrimPrefix(base, "[]") + + for _, prefix := range []string{"corev1.", "configv1.", "metav1.", "hypershiftv1beta1.", "v1alpha1."} { + if strings.Contains(base, prefix) { + return strings.TrimSuffix(prefix, ".") + } + } + + if alias, ok := typeQualifiers[base]; ok { + return alias + } + + return "" +} + +// qualifyType adds package qualifiers to unqualified types that need them +func (g *Generator) qualifyType(goType string) string { + isPointer := strings.HasPrefix(goType, "*") + baseType := strings.TrimPrefix(goType, "*") + + if idx := strings.LastIndex(baseType, "."); idx != -1 { + baseType = baseType[idx+1:] + } + + if alias, ok := typeQualifiers[baseType]; ok && alias == "v1alpha1" { + if isPointer { + return "*v1alpha1." + baseType + } + return "v1alpha1." + baseType + } + + return goType +} + +// generateRESTTypes generates REST type definitions (Phase 1) +func (g *Generator) generateRESTTypes() error { + restDir := filepath.Join(g.OutputDir, "rest") + if err := g.ensureDir(restDir); err != nil { + return err + } + + // Generate REST types for main resource types + resourceTypes := []string{ + "Cluster", "ClusterSpec", "ClusterStatus", + "NodePool", "NodePoolSpec", "NodePoolStatus", + "ClusterReference", // Referenced by NodePoolSpec + } + + for _, typeName := range resourceTypes { + ti, exists := g.typeInfos[typeName] + if !exists { + // Type might not exist (e.g., NodePool not fully implemented yet) + continue + } + + // Generate REST type + code := g.generateRESTType(ti) + + // Write to rest/{typename}_types.go + filename := strings.ToLower(typeName) + "_types.go" + if err := g.writeFile(filepath.Join("rest", filename), code); err != nil { + return fmt.Errorf("writing REST type %s: %w", typeName, err) + } + } + + // Generate passthrough types if they exist + for typeName := range g.typeInfos { + if strings.Contains(typeName, "Passthrough") { + ti := g.typeInfos[typeName] + code := g.generateRESTType(ti) + filename := strings.ToLower(typeName) + "_types.go" + if err := g.writeFile(filepath.Join("rest", filename), code); err != nil { + return fmt.Errorf("writing REST type %s: %w", typeName, err) + } + } + } + + return nil +} + +// generateRESTType generates a REST type from a CRD type +func (g *Generator) generateRESTType(ti *typeInfo) string { + var b strings.Builder + + // Header + b.WriteString("// Code generated by conversion-gen. DO NOT EDIT.\n\n") + b.WriteString("package rest\n\n") + + // Filter visible fields FIRST (before checking imports) + visibleFields := []*fieldInfo{} + for _, fi := range ti.Fields { + if !fi.Hidden { + visibleFields = append(visibleFields, fi) + } + } + + // Check which imports we need (based on VISIBLE fields only) + restImports := map[string]bool{} + for _, fi := range visibleFields { + goType := g.qualifyType(fi.GoType) + if alias := detectTypeImport(goType); alias != "" { + restImports[alias] = true + } + } + needsMetav1 := restImports["metav1"] + needsHyperShift := restImports["hypershiftv1beta1"] + needsV1alpha1 := restImports["v1alpha1"] + + // Write imports + if needsMetav1 || needsHyperShift || needsV1alpha1 { + b.WriteString("import (\n") + if needsHyperShift { + b.WriteString("\thypershiftv1beta1 \"github.com/openshift/hypershift/api/hypershift/v1beta1\"\n") + } + if needsMetav1 { + b.WriteString("\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n") + } + if needsV1alpha1 { + fmt.Fprintf(&b, "\tv1alpha1 \"%s\"\n", g.CRDPackage) + } + b.WriteString(")\n\n") + } + + // Type comment + if ti.Doc != nil { + docText := ti.Doc.Text() + lines := strings.Split(strings.TrimSpace(docText), "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "//") { + b.WriteString("// ") + } + b.WriteString(line + "\n") + } + } else { + fmt.Fprintf(&b, "// %s is the REST representation of %s (visible fields only)\n", ti.Name, ti.Name) + } + + // Struct header + fmt.Fprintf(&b, "type %s struct {\n", ti.Name) + + // Generate fields + for _, fi := range visibleFields { + // Field comment + if fi.Doc != nil { + docText := fi.Doc.Text() + lines := strings.Split(strings.TrimSpace(docText), "\n") + for _, line := range lines { + if line != "" { + if !strings.HasPrefix(line, "//") { + b.WriteString("\t// ") + } else { + b.WriteString("\t") + } + b.WriteString(line + "\n") + } + } + } + + // Construct JSON tag + jsonTag := fi.JSONName + if fi.Field.Tag != nil { + // Preserve omitempty and other options + tag := strings.Trim(fi.Field.Tag.Value, "`") + for _, part := range strings.Fields(tag) { + if strings.HasPrefix(part, "json:") { + jsonTag = strings.Trim(strings.TrimPrefix(part, "json:"), "\"") + break + } + } + } + + // Field definition - qualify type if needed + goType := g.qualifyType(fi.GoType) + fmt.Fprintf(&b, "\t%s %s `json:\"%s\"`\n", fi.GoName, goType, jsonTag) + } + + b.WriteString("}\n") + + return b.String() +} + +// generateServiceSetFields generates the ServiceSetFields struct (Phase 2) +func (g *Generator) generateServiceSetFields() error { + // Collect all service-set fields from registry + type serviceSetField struct { + GoName string + GoType string + JSONTag string + FieldPath string + } + + fieldsMap := make(map[string]serviceSetField) + + for path, meta := range registry.FieldRegistry { + if meta.WriteMode == registry.ServiceSet { + fieldsMap[path] = serviceSetField{ + GoName: g.pathToGoName(path), + GoType: g.inferTypeFromPath(path), + JSONTag: g.pathToJSONTag(path), + FieldPath: path, + } + } + } + + // Convert map to slice for sorting + var fields []serviceSetField + for _, f := range fieldsMap { + fields = append(fields, f) + } + + // Sort for consistent output + sort.Slice(fields, func(i, j int) bool { + return fields[i].GoName < fields[j].GoName + }) + + // Qualify types and detect which imports are actually needed + type qualifiedField struct { + serviceSetField + QualifiedType string + } + var qFields []qualifiedField + ssfImports := map[string]bool{} + + for _, f := range fields { + qt := g.qualifyType(f.GoType) + if alias := detectTypeImport(qt); alias != "" { + ssfImports[alias] = true + } + qFields = append(qFields, qualifiedField{serviceSetField: f, QualifiedType: qt}) + } + needsCorev1 := ssfImports["corev1"] + needsConfigv1 := ssfImports["configv1"] + needsMetav1 := ssfImports["metav1"] + needsHyperShift := ssfImports["hypershiftv1beta1"] + needsV1alpha1 := ssfImports["v1alpha1"] + + // Derive package name and output path from OutputDir. + // The parent of OutputDir is where types.go lives (alongside the version subdirectory). + // e.g., OutputDir = ".../conversion/v1alpha1" → typesDir = ".../conversion", pkg = "conversion" + typesDir := filepath.Dir(g.OutputDir) + pkgName := filepath.Base(typesDir) + + // Generate code + var b strings.Builder + + b.WriteString("// Code generated by conversion-gen. DO NOT EDIT.\n\n") + fmt.Fprintf(&b, "package %s\n\n", pkgName) + + if needsCorev1 || needsConfigv1 || needsMetav1 || needsHyperShift || needsV1alpha1 { + b.WriteString("import (\n") + if needsConfigv1 { + b.WriteString("\tconfigv1 \"github.com/openshift/api/config/v1\"\n") + } + if needsHyperShift { + b.WriteString("\thypershiftv1beta1 \"github.com/openshift/hypershift/api/hypershift/v1beta1\"\n") + } + if needsCorev1 { + b.WriteString("\tcorev1 \"k8s.io/api/core/v1\"\n") + } + if needsMetav1 { + b.WriteString("\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n") + } + if needsV1alpha1 { + fmt.Fprintf(&b, "\tv1alpha1 \"%s\"\n", g.CRDPackage) + } + b.WriteString(")\n\n") + } + + b.WriteString("// ServiceSetFields contains platform-managed fields injected during UnprojectX conversions\n") + b.WriteString("type ServiceSetFields struct {\n") + + for _, f := range qFields { + fmt.Fprintf(&b, "\t// %s is service-set (platform-managed, hidden from API)\n", f.GoName) + fmt.Fprintf(&b, "\t%s %s `json:\"%s\"`\n", f.GoName, f.QualifiedType, f.JSONTag) + } + + b.WriteString("}\n") + + // Write types.go into the parent of OutputDir + typesPath := filepath.Join(typesDir, "types.go") + if err := g.ensureDir(typesDir); err != nil { + return err + } + return os.WriteFile(typesPath, []byte(b.String()), 0644) +} + +// pathToGoName converts a field path to a Go field name using all segments +// after stripping the "spec." prefix so that distinct paths produce distinct names. +// e.g., "spec.accountId" -> "AccountID", "spec.hostedCluster.release" -> "HostedClusterRelease" +func (g *Generator) pathToGoName(path string) string { + path = strings.TrimPrefix(path, "spec.") + parts := strings.Split(path, ".") + if len(parts) == 0 { + return "" + } + + var name string + for _, part := range parts { + part = strings.ReplaceAll(part, "Id", "ID") + part = strings.ReplaceAll(part, "Arn", "ARN") + if len(part) > 0 && part[0] >= 'a' && part[0] <= 'z' { + part = strings.ToUpper(string(part[0])) + part[1:] + } + name += part + } + return name +} + +// pathToJSONTag derives a unique JSON tag from a field path +// e.g., "spec.accountId" -> "accountId", "spec.hostedCluster.release" -> "hostedCluster.release" +func (g *Generator) pathToJSONTag(path string) string { + return strings.TrimPrefix(path, "spec.") +} + +// inferTypeFromPath infers the Go type for a field path +func (g *Generator) inferTypeFromPath(path string) string { + // Try to find the field in parsed types + for _, ti := range g.typeInfos { + for _, fi := range ti.Fields { + if fi.FieldPath == path { + return fi.GoType + } + } + } + + // Default to string if not found + return "string" +} + +// generateConversionFunctions generates conversion functions (Phase 3) +func (g *Generator) generateConversionFunctions() error { + // Generate for Cluster and NodePool + resources := []string{"Cluster", "NodePool"} + + for _, resource := range resources { + // Check if types exist + specType := resource + "Spec" + _, hasSpec := g.typeInfos[specType] + if !hasSpec { + continue // Skip if resource not fully defined + } + + // Generate conversion functions + code := g.generateResourceConversions(resource) + + // Write to {resource}.go + filename := strings.ToLower(resource) + ".go" + if err := g.writeFile(filename, code); err != nil { + return fmt.Errorf("writing conversions for %s: %w", resource, err) + } + } + + return nil +} + +// generateResourceConversions generates Project and Unproject functions for a resource +func (g *Generator) generateResourceConversions(resource string) string { + var b strings.Builder + + // Derive package name from OutputDir (e.g., ".../conversion/v1alpha1" → "v1alpha1") + convPkgName := filepath.Base(g.OutputDir) + + // Derive import paths for the parent (types) package and the rest sub-package + parentPkg := g.outputImportPath() + restPkg := parentPkg + "/" + convPkgName + "/rest" + + // Header + b.WriteString("// Code generated by conversion-gen. DO NOT EDIT.\n\n") + fmt.Fprintf(&b, "package %s\n\n", convPkgName) + + // Imports + b.WriteString("import (\n") + fmt.Fprintf(&b, "\tv1alpha1 \"%s\"\n", g.CRDPackage) + fmt.Fprintf(&b, "\t\"%s\"\n", parentPkg) + fmt.Fprintf(&b, "\t\"%s\"\n", restPkg) + b.WriteString(")\n\n") + + // Project function (CRD → REST) + b.WriteString(g.generateProjectFunction(resource)) + b.WriteString("\n") + + // Unproject function (REST → CRD) + b.WriteString(g.generateUnprojectFunction(resource)) + + return b.String() +} + +// generateProjectFunction generates the ProjectX function (CRD → REST) +func (g *Generator) generateProjectFunction(resource string) string { + var b strings.Builder + + specType := resource + "Spec" + statusType := resource + "Status" + + fmt.Fprintf(&b, "// Project%s converts CRD %s to REST (visible fields only)\n", resource, resource) + fmt.Fprintf(&b, "func Project%s(crd *v1alpha1.%s) *rest.%s {\n", resource, resource, resource) + b.WriteString("\tif crd == nil {\n") + b.WriteString("\t\treturn nil\n") + b.WriteString("\t}\n\n") + + fmt.Fprintf(&b, "\treturn &rest.%s{\n", resource) + fmt.Fprintf(&b, "\t\tSpec: project%s(crd.Spec),\n", specType) + fmt.Fprintf(&b, "\t\tStatus: project%s(crd.Status),\n", statusType) + b.WriteString("\t}\n") + b.WriteString("}\n\n") + + // Helper for Spec + b.WriteString(g.generateProjectSpecFunction(resource, specType)) + b.WriteString("\n") + + // Helper for Status + b.WriteString(g.generateProjectStatusFunction(resource, statusType)) + + return b.String() +} + +// generateProjectSpecFunction generates projectXSpec helper +func (g *Generator) generateProjectSpecFunction(_, specType string) string { + var b strings.Builder + + ti, exists := g.typeInfos[specType] + if !exists { + return "" + } + + fmt.Fprintf(&b, "// project%s converts CRD %s to REST\n", specType, specType) + fmt.Fprintf(&b, "func project%s(crd v1alpha1.%s) rest.%s {\n", specType, specType, specType) + fmt.Fprintf(&b, "\treturn rest.%s{\n", specType) + + // Copy visible fields only + for _, fi := range ti.Fields { + if fi.Hidden { + continue + } + + // Check if this field is a mirror type that needs conversion + if IsMirrorType(fi.GoName) { + // Use auto-generated conversion helper + mapping := GetMirrorMapping(fi.GoName) + if mapping != nil { + // Extract base type name (e.g., "ClusterConfiguration" from "*hypershiftv1beta1.ClusterConfiguration") + baseType := strings.TrimPrefix(fi.GoType, "*") + baseType = strings.TrimPrefix(baseType, "[]") + // Remove package qualifier (e.g., "hypershiftv1beta1." or "v1alpha1.") + if idx := strings.LastIndex(baseType, "."); idx != -1 { + baseType = baseType[idx+1:] + } + + // Generate conversion helper call + // ProjectCluster converts CRD (v1beta1) to REST (v1alpha1), so we need v1beta1 → v1alpha1 + // E.g., ConvertClusterConfiguration_v1beta1_to_v1alpha1(crd.Configuration) + fmt.Fprintf(&b, "\t\t%s: Convert%s_v1beta1_to_v1alpha1(crd.%s),\n", + fi.GoName, baseType, fi.GoName) + continue + } + } + + // Check if this is a custom type that needs conversion + needsHelper := false + baseType := strings.TrimPrefix(fi.GoType, "*") // Remove pointer + baseType = strings.TrimPrefix(baseType, "[]") // Remove slice + if _, isCustom := g.typeInfos[baseType]; isCustom { + needsHelper = true + } + + if needsHelper { + isPointer := strings.HasPrefix(fi.GoType, "*") + isSlice := strings.HasPrefix(fi.GoType, "[]") + if isPointer { + fmt.Fprintf(&b, "\t\t%s: project%sPtr(crd.%s),\n", fi.GoName, baseType, fi.GoName) + } else if isSlice { + fmt.Fprintf(&b, "\t\t%s: project%sSlice(crd.%s),\n", fi.GoName, baseType, fi.GoName) + } else { + fmt.Fprintf(&b, "\t\t%s: project%s(crd.%s),\n", fi.GoName, baseType, fi.GoName) + } + } else { + fmt.Fprintf(&b, "\t\t%s: crd.%s,\n", fi.GoName, fi.GoName) + } + } + + b.WriteString("\t}\n") + b.WriteString("}\n") + + return b.String() +} + +// generateProjectStatusFunction generates projectXStatus helper +func (g *Generator) generateProjectStatusFunction(_, statusType string) string { + var b strings.Builder + + ti, exists := g.typeInfos[statusType] + if !exists { + return "" + } + + fmt.Fprintf(&b, "// project%s converts CRD %s to REST\n", statusType, statusType) + fmt.Fprintf(&b, "func project%s(crd v1alpha1.%s) rest.%s {\n", statusType, statusType, statusType) + fmt.Fprintf(&b, "\treturn rest.%s{\n", statusType) + + // Copy all fields (status fields are typically all visible) + for _, fi := range ti.Fields { + if fi.Hidden { + continue + } + fmt.Fprintf(&b, "\t\t%s: crd.%s,\n", fi.GoName, fi.GoName) + } + + b.WriteString("\t}\n") + b.WriteString("}\n") + + return b.String() +} + +// generateUnprojectFunction generates the UnprojectX function (REST → CRD) +func (g *Generator) generateUnprojectFunction(resource string) string { + var b strings.Builder + + specType := resource + "Spec" + + fmt.Fprintf(&b, "// Unproject%s converts REST %sSpec to CRD with service-set enrichment\n", resource, resource) + fmt.Fprintf(&b, "func Unproject%s(spec *rest.%s, enrichment *conversion.ServiceSetFields) *v1alpha1.%s {\n", resource, specType, specType) + b.WriteString("\tif spec == nil {\n") + b.WriteString("\t\treturn nil\n") + b.WriteString("\t}\n\n") + + ti, exists := g.typeInfos[specType] + if !exists { + fmt.Fprintf(&b, "\treturn &v1alpha1.%s{}\n", specType) + b.WriteString("}\n") + return b.String() + } + + fmt.Fprintf(&b, "\tcrdSpec := &v1alpha1.%s{\n", specType) + + // Copy visible fields from REST + b.WriteString("\t\t// Visible fields from REST request\n") + for _, fi := range ti.Fields { + if fi.Hidden { + continue + } + + // Check if this field is a mirror type that needs conversion + if IsMirrorType(fi.GoName) { + // Use auto-generated conversion helper (reverse direction) + mapping := GetMirrorMapping(fi.GoName) + if mapping != nil { + // Extract base type name + baseType := strings.TrimPrefix(fi.GoType, "*") + baseType = strings.TrimPrefix(baseType, "[]") + // Remove package qualifier + if idx := strings.LastIndex(baseType, "."); idx != -1 { + baseType = baseType[idx+1:] + } + + // Generate conversion helper call + // UnprojectCluster converts REST (v1alpha1) to CRD (v1beta1), so we need v1alpha1 → v1beta1 + // E.g., ConvertClusterConfiguration_v1alpha1_to_v1beta1(spec.Configuration) + fmt.Fprintf(&b, "\t\t%s: Convert%s_v1alpha1_to_v1beta1(spec.%s),\n", + fi.GoName, baseType, fi.GoName) + continue + } + } + + // Check if this is a custom type that needs conversion + needsHelper := false + baseType := strings.TrimPrefix(fi.GoType, "*") + baseType = strings.TrimPrefix(baseType, "[]") + if _, isCustom := g.typeInfos[baseType]; isCustom { + needsHelper = true + } + + if needsHelper { + isPointer := strings.HasPrefix(fi.GoType, "*") + isSlice := strings.HasPrefix(fi.GoType, "[]") + if isPointer { + fmt.Fprintf(&b, "\t\t%s: unproject%sPtr(spec.%s),\n", fi.GoName, baseType, fi.GoName) + } else if isSlice { + fmt.Fprintf(&b, "\t\t%s: unproject%sSlice(spec.%s),\n", fi.GoName, baseType, fi.GoName) + } else { + fmt.Fprintf(&b, "\t\t%s: unproject%s(spec.%s),\n", fi.GoName, baseType, fi.GoName) + } + } else { + fmt.Fprintf(&b, "\t\t%s: spec.%s,\n", fi.GoName, fi.GoName) + } + } + + b.WriteString("\t}\n\n") + + // Add service-set fields from enrichment + b.WriteString("\t// Service-set fields from platform enrichment\n") + b.WriteString("\tif enrichment != nil {\n") + + for _, fi := range ti.Fields { + if fi.WriteMode == registry.ServiceSet { + enrichField := g.pathToGoName(fi.FieldPath) + fmt.Fprintf(&b, "\t\tcrdSpec.%s = enrichment.%s\n", fi.GoName, enrichField) + } + } + + b.WriteString("\t}\n\n") + b.WriteString("\treturn crdSpec\n") + b.WriteString("}\n\n") + + // Add helper functions for passthrough types + b.WriteString(g.generatePassthroughHelpers(specType)) + + return b.String() +} + +// generatePassthroughHelpers generates project/unproject helpers for passthrough types +func (g *Generator) generatePassthroughHelpers(specType string) string { + var b strings.Builder + + ti, exists := g.typeInfos[specType] + if !exists { + return "" + } + + // Find custom type fields (passthrough and others like ClusterReference) + for _, fi := range ti.Fields { + // Check if this is a custom type (exists in our type registry) + baseType := strings.TrimPrefix(fi.GoType, "*") + baseType = strings.TrimPrefix(baseType, "[]") + + pti, exists := g.typeInfos[baseType] + if !exists { + continue + } + + customType := baseType + isPointer := strings.HasPrefix(fi.GoType, "*") + isSlice := strings.HasPrefix(fi.GoType, "[]") + + // Generate value helpers if not already emitted + if !g.emittedHelpers[customType] { + g.emittedHelpers[customType] = true + + // Generate project helper + fmt.Fprintf(&b, "// project%s converts CRD type to REST\n", customType) + fmt.Fprintf(&b, "func project%s(crd v1alpha1.%s) rest.%s {\n", customType, customType, customType) + fmt.Fprintf(&b, "\treturn rest.%s{\n", customType) + + for _, pfi := range pti.Fields { + if !pfi.Hidden { + // Check if this is a mirror type field + if IsMirrorType(pfi.GoName) { + mapping := GetMirrorMapping(pfi.GoName) + if mapping != nil { + // Extract base type + fieldBaseType := strings.TrimPrefix(pfi.GoType, "*") + fieldBaseType = strings.TrimPrefix(fieldBaseType, "[]") + // Remove package qualifier + if idx := strings.LastIndex(fieldBaseType, "."); idx != -1 { + fieldBaseType = fieldBaseType[idx+1:] + } + + // Use conversion helper (CRD v1beta1 → REST v1alpha1) + fmt.Fprintf(&b, "\t\t%s: Convert%s_v1beta1_to_v1alpha1(crd.%s),\n", + pfi.GoName, fieldBaseType, pfi.GoName) + continue + } + } + fmt.Fprintf(&b, "\t\t%s: crd.%s,\n", pfi.GoName, pfi.GoName) + } + } + + b.WriteString("\t}\n") + b.WriteString("}\n\n") + + // Generate unproject helper + fmt.Fprintf(&b, "// unproject%s converts REST type to CRD\n", customType) + fmt.Fprintf(&b, "func unproject%s(rest rest.%s) v1alpha1.%s {\n", customType, customType, customType) + fmt.Fprintf(&b, "\treturn v1alpha1.%s{\n", customType) + + for _, pfi := range pti.Fields { + if !pfi.Hidden { + // Check if this is a mirror type field + if IsMirrorType(pfi.GoName) { + mapping := GetMirrorMapping(pfi.GoName) + if mapping != nil { + // Extract base type + fieldBaseType := strings.TrimPrefix(pfi.GoType, "*") + fieldBaseType = strings.TrimPrefix(fieldBaseType, "[]") + // Remove package qualifier + if idx := strings.LastIndex(fieldBaseType, "."); idx != -1 { + fieldBaseType = fieldBaseType[idx+1:] + } + + // Use conversion helper (REST v1alpha1 → CRD v1beta1) + fmt.Fprintf(&b, "\t\t%s: Convert%s_v1alpha1_to_v1beta1(rest.%s),\n", + pfi.GoName, fieldBaseType, pfi.GoName) + continue + } + } + fmt.Fprintf(&b, "\t\t%s: rest.%s,\n", pfi.GoName, pfi.GoName) + } + } + + b.WriteString("\t}\n") + b.WriteString("}\n\n") + } + + // Generate pointer wrappers if needed + if isPointer && !g.emittedHelpers[customType+"Ptr"] { + g.emittedHelpers[customType+"Ptr"] = true + + fmt.Fprintf(&b, "func project%sPtr(crd *v1alpha1.%s) *rest.%s {\n", customType, customType, customType) + b.WriteString("\tif crd == nil {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(&b, "\tv := project%s(*crd)\n", customType) + b.WriteString("\treturn &v\n") + b.WriteString("}\n\n") + + fmt.Fprintf(&b, "func unproject%sPtr(r *rest.%s) *v1alpha1.%s {\n", customType, customType, customType) + b.WriteString("\tif r == nil {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(&b, "\tv := unproject%s(*r)\n", customType) + b.WriteString("\treturn &v\n") + b.WriteString("}\n\n") + } + + // Generate slice wrappers if needed + if isSlice && !g.emittedHelpers[customType+"Slice"] { + g.emittedHelpers[customType+"Slice"] = true + + fmt.Fprintf(&b, "func project%sSlice(crd []v1alpha1.%s) []rest.%s {\n", customType, customType, customType) + b.WriteString("\tif crd == nil {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(&b, "\tout := make([]rest.%s, len(crd))\n", customType) + b.WriteString("\tfor i := range crd {\n") + fmt.Fprintf(&b, "\t\tout[i] = project%s(crd[i])\n", customType) + b.WriteString("\t}\n") + b.WriteString("\treturn out\n") + b.WriteString("}\n\n") + + fmt.Fprintf(&b, "func unproject%sSlice(r []rest.%s) []v1alpha1.%s {\n", customType, customType, customType) + b.WriteString("\tif r == nil {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(&b, "\tout := make([]v1alpha1.%s, len(r))\n", customType) + b.WriteString("\tfor i := range r {\n") + fmt.Fprintf(&b, "\t\tout[i] = unproject%s(r[i])\n", customType) + b.WriteString("\t}\n") + b.WriteString("\treturn out\n") + b.WriteString("}\n\n") + } + } + + return b.String() +} + +// ensureDir creates a directory if it doesn't exist +func (g *Generator) ensureDir(dir string) error { + return os.MkdirAll(dir, 0755) +} + +// outputImportPath returns the Go import path for the parent output package. +// If OutputPackage is set explicitly, it is used directly. Otherwise, the path +// is derived from OutputDir using a convention-based default. +func (g *Generator) outputImportPath() string { + if g.OutputPackage != "" { + return g.OutputPackage + } + return "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/conversion" +} + +// writeFile writes content to a file, creating parent directories as needed +func (g *Generator) writeFile(relativePath, content string) error { + fullPath := filepath.Join(g.OutputDir, relativePath) + + if err := g.ensureDir(filepath.Dir(fullPath)); err != nil { + return err + } + + return os.WriteFile(fullPath, []byte(content), 0644) +} diff --git a/hack/api-codegen/pkg/conversion/generator_test.go b/hack/api-codegen/pkg/conversion/generator_test.go new file mode 100644 index 00000000..7d152dbf --- /dev/null +++ b/hack/api-codegen/pkg/conversion/generator_test.go @@ -0,0 +1,179 @@ +package conversion + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewGenerator(t *testing.T) { + gen := NewGenerator("v1alpha1", "test/pkg", []string{"/test/dir"}, "/test/output") + + if gen.APIVersion != "v1alpha1" { + t.Errorf("Expected APIVersion v1alpha1, got %s", gen.APIVersion) + } + if gen.CRDPackage != "test/pkg" { + t.Errorf("Expected CRDPackage test/pkg, got %s", gen.CRDPackage) + } + if gen.OutputDir != "/test/output" { + t.Errorf("Expected OutputDir /test/output, got %s", gen.OutputDir) + } + if len(gen.InputDirs) != 1 || gen.InputDirs[0] != "/test/dir" { + t.Errorf("Expected InputDirs [/test/dir], got %v", gen.InputDirs) + } + if gen.knownTypes == nil { + t.Error("Expected knownTypes map to be initialized") + } + if gen.typeInfos == nil { + t.Error("Expected typeInfos map to be initialized") + } +} + +func TestBuildFieldPath(t *testing.T) { + gen := NewGenerator("v1alpha1", "test", []string{}, "") + + tests := []struct { + name string + typeName string + jsonName string + want string + }{ + { + name: "Spec type", + typeName: "ClusterSpec", + jsonName: "displayName", + want: "spec.displayName", + }, + { + name: "Status type", + typeName: "ClusterStatus", + jsonName: "state", + want: "status.state", + }, + { + name: "HostedCluster passthrough", + typeName: "HostedClusterSpecPassthrough", + jsonName: "platform", + want: "spec.hostedCluster.platform", + }, + { + name: "NodePool passthrough", + typeName: "NodePoolSpecPassthrough", + jsonName: "release", + want: "spec.nodePool.release", + }, + { + name: "Other type", + typeName: "ClusterReference", + jsonName: "name", + want: "name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gen.buildFieldPath(tt.typeName, tt.jsonName) + if got != tt.want { + t.Errorf("buildFieldPath(%q, %q) = %q, want %q", tt.typeName, tt.jsonName, got, tt.want) + } + }) + } +} + +func TestExtractJSONTag(t *testing.T) { + // This would require creating AST field structures + // For now, just test the basic logic is correct + t.Skip("Requires AST field creation - tested via integration") +} + +func TestExprToString(t *testing.T) { + // This would require creating AST expression structures + // For now, just test the basic logic is correct + t.Skip("Requires AST expression creation - tested via integration") +} + +func TestGeneratePassthroughHelpers_PointerAndSlice(t *testing.T) { + gen := NewGenerator("v1alpha1", "test/pkg", nil, "/out/v1alpha1") + gen.typeInfos["ChildType"] = &typeInfo{ + Name: "ChildType", + StructType: nil, + Fields: []*fieldInfo{ + {GoName: "Name", JSONName: "name", GoType: "string"}, + }, + } + gen.typeInfos["TestSpec"] = &typeInfo{ + Name: "TestSpec", + StructType: nil, + Fields: []*fieldInfo{ + {GoName: "PtrField", JSONName: "ptrField", GoType: "*ChildType"}, + {GoName: "SliceField", JSONName: "sliceField", GoType: "[]ChildType"}, + {GoName: "ValField", JSONName: "valField", GoType: "ChildType"}, + }, + } + + code := gen.generatePassthroughHelpers("TestSpec") + + // Value helpers should exist + if !strings.Contains(code, "func projectChildType(crd v1alpha1.ChildType) rest.ChildType") { + t.Error("missing value project helper") + } + if !strings.Contains(code, "func unprojectChildType(rest rest.ChildType) v1alpha1.ChildType") { + t.Error("missing value unproject helper") + } + // Pointer helpers should exist + if !strings.Contains(code, "func projectChildTypePtr(crd *v1alpha1.ChildType) *rest.ChildType") { + t.Error("missing pointer project helper") + } + if !strings.Contains(code, "func unprojectChildTypePtr(r *rest.ChildType) *v1alpha1.ChildType") { + t.Error("missing pointer unproject helper") + } + // Pointer helpers must nil-check + if !strings.Contains(code, "if crd == nil") { + t.Error("pointer project helper missing nil check") + } + if !strings.Contains(code, "if r == nil") { + t.Error("pointer unproject helper missing nil check") + } + // Slice helpers should exist + if !strings.Contains(code, "func projectChildTypeSlice(crd []v1alpha1.ChildType) []rest.ChildType") { + t.Error("missing slice project helper") + } + if !strings.Contains(code, "func unprojectChildTypeSlice(r []rest.ChildType) []v1alpha1.ChildType") { + t.Error("missing slice unproject helper") + } +} + +// Note: needsFieldPrefix and makeUniqueFieldName are private helper methods +// They are tested indirectly via the integration tests that verify +// the generated ServiceSetFields struct has correctly prefixed field names + +func TestEnsureDir(t *testing.T) { + gen := NewGenerator("v1alpha1", "test", []string{}, "") + + // Create temp dir for test + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "test", "nested", "dir") + + // Directory should not exist yet + if _, err := os.Stat(testDir); !os.IsNotExist(err) { + t.Fatalf("Test directory should not exist yet") + } + + // Create it + if err := gen.ensureDir(testDir); err != nil { + t.Fatalf("ensureDir failed: %v", err) + } + + // Should exist now + if stat, err := os.Stat(testDir); err != nil { + t.Fatalf("Directory was not created: %v", err) + } else if !stat.IsDir() { + t.Fatal("Path exists but is not a directory") + } + + // Calling again should be idempotent + if err := gen.ensureDir(testDir); err != nil { + t.Fatalf("ensureDir should be idempotent: %v", err) + } +} diff --git a/hack/api-codegen/pkg/conversion/mirror_types.go b/hack/api-codegen/pkg/conversion/mirror_types.go new file mode 100644 index 00000000..83455e48 --- /dev/null +++ b/hack/api-codegen/pkg/conversion/mirror_types.go @@ -0,0 +1,59 @@ +// Code generated by conversion-gen. DO NOT EDIT. + +package conversion + +// MirrorTypeMapping defines a type that we mirror from HyperShift upstream +// and need automatic conversion logic for +type MirrorTypeMapping struct { + FieldName string // Name of the field in the struct (e.g., "Configuration", "AutoNode") + HyperFleetType string // Our mirror type (e.g., "v1alpha1.ClusterConfiguration") + HyperShiftType string // Upstream HyperShift type (e.g., "v1beta1.ClusterConfiguration") + ConversionStrategy string // "json-roundtrip" or "field-by-field" +} + +// mirrorTypeMappings is the registry of all mirror types requiring conversion +// +// These are types where: +// 1. HyperFleet owns a mirror version (in api/v1alpha1/) to add granular markers +// 2. HyperShift has the upstream version (in hypershift/api/hypershift/v1beta1/) +// 3. Conversion functions need to convert between the two +// +// When a mirror type exists, the conversion generator will: +// - Detect type mismatches during conversion generation +// - Generate conversion helper functions automatically +// - Use helpers in ProjectX/UnprojectX conversion functions +var mirrorTypeMappings = []MirrorTypeMapping{ + // Configuration: HyperFleet owns a mirror type (api/v1alpha1/configuration.go) to enable granular markers + // on kubelet and machine config fields, while HyperShift has the upstream version in v1beta1 + { + FieldName: "Configuration", + HyperFleetType: "v1alpha1.ClusterConfiguration", + HyperShiftType: "v1beta1.ClusterConfiguration", + ConversionStrategy: "json-roundtrip", + }, + + // Add more mirror types here as needed when types diverge between CRD and REST + // Example: + // { + // FieldName: "Kubelet", + // HyperFleetType: "v1alpha1.KubeletConfig", + // HyperShiftType: "v1beta1.KubeletConfig", + // ConversionStrategy: "json-roundtrip", + // }, +} + +// GetMirrorMapping returns the mirror type mapping for a given field name +// Returns nil if the field is not a mirror type +func GetMirrorMapping(fieldName string) *MirrorTypeMapping { + for i := range mirrorTypeMappings { + if mirrorTypeMappings[i].FieldName == fieldName { + return &mirrorTypeMappings[i] + } + } + return nil +} + +// IsMirrorType returns true if the field name is registered as a mirror type +func IsMirrorType(fieldName string) bool { + return GetMirrorMapping(fieldName) != nil +} diff --git a/hack/api-codegen/pkg/conversion/mirror_types_test.go b/hack/api-codegen/pkg/conversion/mirror_types_test.go new file mode 100644 index 00000000..7dc9431b --- /dev/null +++ b/hack/api-codegen/pkg/conversion/mirror_types_test.go @@ -0,0 +1,168 @@ +package conversion + +import ( + "testing" +) + +func TestGetMirrorMapping(t *testing.T) { + tests := []struct { + name string + fieldName string + wantNil bool + }{ + { + name: "Configuration exists", + fieldName: "Configuration", + wantNil: false, + }, + { + name: "NonExistentField", + fieldName: "NonExistentField", + wantNil: true, + }, + { + name: "Empty string", + fieldName: "", + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetMirrorMapping(tt.fieldName) + if (got == nil) != tt.wantNil { + t.Errorf("GetMirrorMapping() nil = %v, want nil = %v", got == nil, tt.wantNil) + } + if !tt.wantNil && got != nil { + // Verify the mapping has required fields + if got.FieldName != tt.fieldName { + t.Errorf("GetMirrorMapping().FieldName = %s, want %s", got.FieldName, tt.fieldName) + } + if got.HyperFleetType == "" { + t.Error("GetMirrorMapping().HyperFleetType is empty") + } + if got.HyperShiftType == "" { + t.Error("GetMirrorMapping().HyperShiftType is empty") + } + if got.ConversionStrategy == "" { + t.Error("GetMirrorMapping().ConversionStrategy is empty") + } + } + }) + } +} + +func TestGetMirrorMapping_Configuration(t *testing.T) { + mapping := GetMirrorMapping("Configuration") + if mapping == nil { + t.Fatal("GetMirrorMapping(\"Configuration\") returned nil") + } + + if mapping.FieldName != "Configuration" { + t.Errorf("FieldName = %s, want Configuration", mapping.FieldName) + } + if mapping.HyperFleetType != "v1alpha1.ClusterConfiguration" { + t.Errorf("HyperFleetType = %s, want v1alpha1.ClusterConfiguration", mapping.HyperFleetType) + } + if mapping.HyperShiftType != "v1beta1.ClusterConfiguration" { + t.Errorf("HyperShiftType = %s, want v1beta1.ClusterConfiguration", mapping.HyperShiftType) + } + if mapping.ConversionStrategy != "json-roundtrip" { + t.Errorf("ConversionStrategy = %s, want json-roundtrip", mapping.ConversionStrategy) + } +} + +func TestIsMirrorType(t *testing.T) { + tests := []struct { + name string + fieldName string + want bool + }{ + { + name: "Configuration is mirror type", + fieldName: "Configuration", + want: true, + }, + { + name: "NonMirrorField is not mirror type", + fieldName: "NonMirrorField", + want: false, + }, + { + name: "Empty string is not mirror type", + fieldName: "", + want: false, + }, + { + name: "Replicas is not mirror type", + fieldName: "Replicas", + want: false, + }, + { + name: "ClusterName is not mirror type", + fieldName: "ClusterName", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsMirrorType(tt.fieldName) + if got != tt.want { + t.Errorf("IsMirrorType(%s) = %v, want %v", tt.fieldName, got, tt.want) + } + }) + } +} + +func TestMirrorTypeMappings_Completeness(t *testing.T) { + // Verify that all mirror type mappings have required fields + for _, mapping := range mirrorTypeMappings { + if mapping.FieldName == "" { + t.Error("Found mapping with empty FieldName") + } + + if mapping.HyperFleetType == "" { + t.Errorf("Mirror field %s has empty HyperFleetType", mapping.FieldName) + } + + if mapping.HyperShiftType == "" { + t.Errorf("Mirror field %s has empty HyperShiftType", mapping.FieldName) + } + + if mapping.ConversionStrategy == "" { + t.Errorf("Mirror field %s has empty ConversionStrategy", mapping.FieldName) + } + + // Verify we can look it up + found := GetMirrorMapping(mapping.FieldName) + if found == nil { + t.Errorf("Cannot lookup mirror field: %s", mapping.FieldName) + } + } +} + +func TestIsMirrorType_AllMappings(t *testing.T) { + // Verify IsMirrorType works for all registered mirror fields + for _, mapping := range mirrorTypeMappings { + if !IsMirrorType(mapping.FieldName) { + t.Errorf("IsMirrorType(%s) = false, want true (registered mirror field)", mapping.FieldName) + } + } +} + +func TestMirrorTypeMappings_KnownFields(t *testing.T) { + // Verify specific known mirror fields exist + knownFields := []string{"Configuration"} + + for _, fieldName := range knownFields { + if !IsMirrorType(fieldName) { + t.Errorf("Known mirror field %s not found in registry", fieldName) + } + + mapping := GetMirrorMapping(fieldName) + if mapping == nil { + t.Errorf("GetMirrorMapping(%s) returned nil", fieldName) + } + } +} diff --git a/hack/api-codegen/pkg/featuregate/crd_filter.go b/hack/api-codegen/pkg/featuregate/crd_filter.go new file mode 100644 index 00000000..cc0f070f --- /dev/null +++ b/hack/api-codegen/pkg/featuregate/crd_filter.go @@ -0,0 +1,77 @@ +package featuregate + +import ( + "fmt" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/registry" +) + +// FilterCRDFields returns a list of field paths that should be included in the CRD +// for the given feature set +func FilterCRDFields(featureSet FeatureSet) []string { + var includedFields []string + + for fieldPath, meta := range registry.FieldRegistry { + // Skip hidden fields - they never appear in CRDs + if meta.Hidden { + continue + } + + // If field has no gate, it's always included (GA) + if meta.FeatureGate == "" { + includedFields = append(includedFields, fieldPath) + continue + } + + // Check if this feature gate is enabled for the feature set + if IsGateEnabled(meta.FeatureGate, featureSet) { + includedFields = append(includedFields, fieldPath) + } + } + + return includedFields +} + +// FieldsForFeatureSet returns field metadata for all fields available in the given feature set +func FieldsForFeatureSet(featureSet FeatureSet) map[string]registry.FieldMeta { + result := make(map[string]registry.FieldMeta) + + for fieldPath, meta := range registry.FieldRegistry { + // Skip hidden fields + if meta.Hidden { + continue + } + + // If field has no gate, it's always included (GA) + if meta.FeatureGate == "" { + result[fieldPath] = meta + continue + } + + // Check if this feature gate is enabled for the feature set + if IsGateEnabled(meta.FeatureGate, featureSet) { + result[fieldPath] = meta + } + } + + return result +} + +// SummarizeFeatureSet returns a summary of fields available in each feature set +func SummarizeFeatureSet() string { + featureSets := []FeatureSet{Default, TechPreviewNoUpgrade, DevPreviewNoUpgrade} + + summary := "Feature Set Field Summary:\n\n" + + for _, fs := range featureSets { + fields := FieldsForFeatureSet(fs) + gates := GatesForFeatureSet(fs) + + summary += fmt.Sprintf("%s:\n", fs) + summary += fmt.Sprintf(" Total fields: %d\n", len(fields)) + summary += fmt.Sprintf(" Enabled gates: %v\n", gates) + summary += "\n" + } + + return summary +} diff --git a/hack/api-codegen/pkg/featuregate/crd_variant.go b/hack/api-codegen/pkg/featuregate/crd_variant.go new file mode 100644 index 00000000..8cfa5183 --- /dev/null +++ b/hack/api-codegen/pkg/featuregate/crd_variant.go @@ -0,0 +1,255 @@ +package featuregate + +import ( + "fmt" + "io" + "log" + "os" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/registry" +) + +// openAPIKeywords are leaf names in an OpenAPI schema that describe the schema +// itself rather than user-defined properties. shouldIncludeField uses this set +// to avoid warning about paths that are structural, not missing registrations. +var openAPIKeywords = map[string]bool{ + "type": true, "description": true, "required": true, "format": true, + "minimum": true, "maximum": true, "enum": true, "default": true, + "pattern": true, "minLength": true, "maxLength": true, + "minItems": true, "maxItems": true, "uniqueItems": true, + "nullable": true, "readOnly": true, "writeOnly": true, + "example": true, "allOf": true, "oneOf": true, "anyOf": true, "not": true, +} + +// CRDVariantGenerator generates feature-set-specific CRD variants +type CRDVariantGenerator struct { + fieldRegistry map[string]registry.FieldMeta +} + +// NewCRDVariantGenerator creates a new CRD variant generator +func NewCRDVariantGenerator() *CRDVariantGenerator { + return &CRDVariantGenerator{ + fieldRegistry: registry.FieldRegistry, + } +} + +// GenerateVariant reads a base CRD and generates a filtered variant for a feature set +func (g *CRDVariantGenerator) GenerateVariant(inputPath string, outputPath string, featureSet FeatureSet) error { + // Read input CRD + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("reading CRD: %w", err) + } + + // Parse YAML + var crd yaml.Node + if err := yaml.Unmarshal(data, &crd); err != nil { + return fmt.Errorf("parsing YAML: %w", err) + } + + // Filter the CRD based on feature set + ctx := &filterContext{ + featureSet: featureSet, + inSchema: false, + fieldPath: "", + } + if err := g.filterCRDNode(&crd, ctx); err != nil { + return fmt.Errorf("filtering CRD: %w", err) + } + + // Write output + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + + encoder := yaml.NewEncoder(f) + encoder.SetIndent(2) + if err := encoder.Encode(&crd); err != nil { + f.Close() + return fmt.Errorf("writing YAML: %w", err) + } + if err := encoder.Close(); err != nil { + f.Close() + return fmt.Errorf("closing YAML encoder: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("closing output file: %w", err) + } + + return nil +} + +type filterContext struct { + featureSet FeatureSet + inSchema bool // true when inside openAPIV3Schema.properties + fieldPath string // current field path (e.g., "spec.tags") +} + +// filterCRDNode walks the CRD YAML tree and removes fields not available in the feature set +func (g *CRDVariantGenerator) filterCRDNode(node *yaml.Node, ctx *filterContext) error { + if node == nil { + return nil + } + + switch node.Kind { + case yaml.DocumentNode: + // Process document content + for _, child := range node.Content { + if err := g.filterCRDNode(child, ctx); err != nil { + return err + } + } + + case yaml.MappingNode: + // Process key-value pairs + // YAML mappings have alternating key/value nodes + newContent := make([]*yaml.Node, 0, len(node.Content)) + + for i := 0; i < len(node.Content); i += 2 { + if i+1 >= len(node.Content) { + break + } + + keyNode := node.Content[i] + valueNode := node.Content[i+1] + fieldName := keyNode.Value + + // Track when we enter the schema's properties section + enteringSchema := !ctx.inSchema && fieldName == "properties" + + // Save old context + oldInSchema := ctx.inSchema + oldFieldPath := ctx.fieldPath + + // Update context for this key + isStructuralWrapper := fieldName == "items" || fieldName == "additionalProperties" + if enteringSchema { + ctx.inSchema = true + } else if ctx.inSchema && fieldName != "properties" && !isStructuralWrapper { + // We're inside schema properties, build field path + if ctx.fieldPath == "" { + ctx.fieldPath = fieldName + } else { + ctx.fieldPath = ctx.fieldPath + "." + fieldName + } + } + + // Check if we should include this field + shouldInclude := true + if ctx.inSchema && fieldName != "properties" && ctx.fieldPath != "" { + shouldInclude = g.shouldIncludeField(ctx.fieldPath, ctx.featureSet) + } + + if shouldInclude { + // Recurse into value + if err := g.filterCRDNode(valueNode, ctx); err != nil { + return err + } + newContent = append(newContent, keyNode, valueNode) + } + + // Restore context + ctx.inSchema = oldInSchema + ctx.fieldPath = oldFieldPath + } + + node.Content = newContent + + case yaml.SequenceNode: + // Process array elements + for _, child := range node.Content { + if err := g.filterCRDNode(child, ctx); err != nil { + return err + } + } + } + + return nil +} + +// shouldIncludeField checks if a field should be included in the given feature set +func (g *CRDVariantGenerator) shouldIncludeField(fieldPath string, featureSet FeatureSet) bool { + // Check if field is in registry + meta, exists := g.fieldRegistry[fieldPath] + if !exists { + leaf := fieldPath + if idx := strings.LastIndex(fieldPath, "."); idx != -1 { + leaf = fieldPath[idx+1:] + } + if !openAPIKeywords[leaf] && !strings.HasPrefix(leaf, "x-kubernetes-") { + log.Printf("WARNING: field path %q not found in registry, including by default", fieldPath) + } + return true + } + + if meta.Hidden { + return false + } + + // If field has a feature gate, check if it's enabled + if meta.FeatureGate != "" { + return IsGateEnabled(meta.FeatureGate, featureSet) + } + + // No feature gate - always include + return true +} + +// GenerateAllVariants generates CRD variants for all feature sets +func (g *CRDVariantGenerator) GenerateAllVariants(inputPath string, outputDir string, baseName string) error { + featureSets := []struct { + set FeatureSet + suffix string + }{ + {Default, "default"}, + {TechPreviewNoUpgrade, "techpreview"}, + {DevPreviewNoUpgrade, "devpreview"}, + } + + for _, fs := range featureSets { + outputPath := fmt.Sprintf("%s/%s_%s.yaml", outputDir, baseName, fs.suffix) + if err := g.GenerateVariant(inputPath, outputPath, fs.set); err != nil { + return fmt.Errorf("generating %s variant: %w", fs.suffix, err) + } + } + + return nil +} + +// WriteVariantToWriter generates a variant and writes it to a writer (useful for testing) +func (g *CRDVariantGenerator) WriteVariantToWriter(inputPath string, w io.Writer, featureSet FeatureSet) error { + // Read input CRD + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("reading CRD: %w", err) + } + + // Parse YAML + var crd yaml.Node + if err := yaml.Unmarshal(data, &crd); err != nil { + return fmt.Errorf("parsing YAML: %w", err) + } + + // Filter the CRD based on feature set + ctx := &filterContext{ + featureSet: featureSet, + inSchema: false, + fieldPath: "", + } + if err := g.filterCRDNode(&crd, ctx); err != nil { + return fmt.Errorf("filtering CRD: %w", err) + } + + // Write to writer + encoder := yaml.NewEncoder(w) + encoder.SetIndent(2) + if err := encoder.Encode(&crd); err != nil { + return fmt.Errorf("writing YAML: %w", err) + } + + return nil +} diff --git a/hack/api-codegen/pkg/featuregate/crd_variant_test.go b/hack/api-codegen/pkg/featuregate/crd_variant_test.go new file mode 100644 index 00000000..6771557d --- /dev/null +++ b/hack/api-codegen/pkg/featuregate/crd_variant_test.go @@ -0,0 +1,292 @@ +package featuregate + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/registry" +) + +func TestCRDVariantGenerator_shouldIncludeField(t *testing.T) { + g := &CRDVariantGenerator{ + fieldRegistry: map[string]registry.FieldMeta{ + "spec.tags": { + FieldPath: "spec.tags", + WriteMode: registry.Mutable, + FeatureGate: "HyperFleetAutoScaling", // TechPreview gate + }, + "spec.displayName": { + FieldPath: "spec.displayName", + WriteMode: registry.Mutable, + // No feature gate + }, + "spec.creatorARN": { + FieldPath: "spec.creatorARN", + WriteMode: registry.ServiceSet, + Hidden: true, + }, + "spec.hiddenGated": { + FieldPath: "spec.hiddenGated", + WriteMode: registry.ServiceSet, + Hidden: true, + FeatureGate: "HyperFleetAutoScaling", + }, + }, + } + + tests := []struct { + name string + fieldPath string + featureSet FeatureSet + want bool + }{ + { + name: "gated field with Default - excluded", + fieldPath: "spec.tags", + featureSet: Default, + want: false, + }, + { + name: "gated field with TechPreview - included", + fieldPath: "spec.tags", + featureSet: TechPreviewNoUpgrade, + want: true, + }, + { + name: "non-gated field with Default - included", + fieldPath: "spec.displayName", + featureSet: Default, + want: true, + }, + { + name: "structural field (not in registry) - included", + fieldPath: "properties", + featureSet: Default, + want: true, + }, + { + name: "type field (not in registry) - included", + fieldPath: "type", + featureSet: Default, + want: true, + }, + { + name: "hidden field with Default - excluded", + fieldPath: "spec.creatorARN", + featureSet: Default, + want: false, + }, + { + name: "hidden field with TechPreview - excluded", + fieldPath: "spec.creatorARN", + featureSet: TechPreviewNoUpgrade, + want: false, + }, + { + name: "hidden and gated field with TechPreview - excluded", + fieldPath: "spec.hiddenGated", + featureSet: TechPreviewNoUpgrade, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := g.shouldIncludeField(tt.fieldPath, tt.featureSet) + if got != tt.want { + t.Errorf("shouldIncludeField(%q, %v) = %v, want %v", tt.fieldPath, tt.featureSet, got, tt.want) + } + }) + } +} + +func TestCRDVariantGenerator_GenerateVariant(t *testing.T) { + // Create a minimal test CRD + testCRD := `apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.hyperfleet.io +spec: + group: hyperfleet.io + names: + kind: Cluster + plural: clusters + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + displayName: + type: string + description: Display name for the cluster + tags: + type: object + description: Customer tags (TechPreview feature) + additionalProperties: + type: string +` + + // Write test CRD to temp file + tmpFile, err := os.CreateTemp("", "test-crd-*.yaml") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + if _, err := tmpFile.WriteString(testCRD); err != nil { + t.Fatalf("writing test CRD: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("closing temp file: %v", err) + } + + // Create generator with test registry + g := &CRDVariantGenerator{ + fieldRegistry: map[string]registry.FieldMeta{ + "spec.tags": { + FieldPath: "spec.tags", + WriteMode: registry.Mutable, + FeatureGate: "HyperFleetAutoScaling", // TechPreview + }, + "spec.displayName": { + FieldPath: "spec.displayName", + WriteMode: registry.Mutable, + }, + }, + } + + tests := []struct { + name string + featureSet FeatureSet + shouldContain []string + shouldNotContain []string + }{ + { + name: "Default variant excludes gated fields", + featureSet: Default, + shouldContain: []string{"displayName"}, + shouldNotContain: []string{"tags:"}, + }, + { + name: "TechPreview variant includes gated fields", + featureSet: TechPreviewNoUpgrade, + shouldContain: []string{"displayName", "tags:"}, + shouldNotContain: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + if err := g.WriteVariantToWriter(tmpFile.Name(), &buf, tt.featureSet); err != nil { + t.Fatalf("GenerateVariant() error = %v", err) + } + + output := buf.String() + + for _, want := range tt.shouldContain { + if !strings.Contains(output, want) { + t.Errorf("output should contain %q but doesn't", want) + } + } + + for _, notWant := range tt.shouldNotContain { + if strings.Contains(output, notWant) { + t.Errorf("output should not contain %q but does", notWant) + } + } + }) + } +} + +func TestCRDVariantGenerator_GenerateAllVariants(t *testing.T) { + // Create minimal test CRD + testCRD := `apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: test.hyperfleet.io +spec: + group: hyperfleet.io + names: + kind: Test + plural: tests +` + + // Write test CRD + tmpFile, err := os.CreateTemp("", "test-crd-*.yaml") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + if _, err := tmpFile.WriteString(testCRD); err != nil { + t.Fatalf("writing test CRD: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("closing temp file: %v", err) + } + + // Create temp output directory + tmpDir := t.TempDir() + + g := NewCRDVariantGenerator() + + // Generate all variants + if err := g.GenerateAllVariants(tmpFile.Name(), tmpDir, "test"); err != nil { + t.Fatalf("GenerateAllVariants() error = %v", err) + } + + // Check that all three variants were created + expectedFiles := []string{ + "test_default.yaml", + "test_techpreview.yaml", + "test_devpreview.yaml", + } + + for _, filename := range expectedFiles { + path := tmpDir + "/" + filename + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected file %s was not created", filename) + } + } +} + +func TestNewCRDVariantGenerator(t *testing.T) { + g := NewCRDVariantGenerator() + if g == nil { + t.Fatal("NewCRDVariantGenerator() returned nil") + } + + // Verify it uses the real registry + if g.fieldRegistry == nil { + t.Error("fieldRegistry is nil") + } + + // Check that canonical prefixed keys exist + canonicalKeys := []string{ + "spec.displayName", + "spec.hostedCluster.release", + "spec.nodePool.release", + } + for _, key := range canonicalKeys { + if _, exists := g.fieldRegistry[key]; !exists { + t.Errorf("expected canonical key %q to exist in registry", key) + } + } + + // Verify bare duplicate keys are absent — these should only appear + // with their canonical spec.-prefixed form. + bareKeys := []string{"release", "platform", "pausedUntil"} + for _, key := range bareKeys { + if _, exists := g.fieldRegistry[key]; exists { + t.Errorf("bare key %q should not exist in registry; use canonical prefixed form", key) + } + } +} diff --git a/hack/api-codegen/pkg/featuregate/featuregate_test.go b/hack/api-codegen/pkg/featuregate/featuregate_test.go new file mode 100644 index 00000000..6c84a9df --- /dev/null +++ b/hack/api-codegen/pkg/featuregate/featuregate_test.go @@ -0,0 +1,214 @@ +package featuregate + +import ( + "strings" + "testing" +) + +func TestFeatureStageHierarchy(t *testing.T) { + tests := []struct { + name string + featureSet FeatureSet + stage FeatureStage + shouldInclude bool + }{ + {"Default includes GA", Default, GA, true}, + {"Default excludes TechPreview", Default, TechPreview, false}, + {"Default excludes DevPreview", Default, DevPreview, false}, + + {"TechPreview includes GA", TechPreviewNoUpgrade, GA, true}, + {"TechPreview includes TechPreview", TechPreviewNoUpgrade, TechPreview, true}, + {"TechPreview excludes DevPreview", TechPreviewNoUpgrade, DevPreview, false}, + + {"DevPreview includes GA", DevPreviewNoUpgrade, GA, true}, + {"DevPreview includes TechPreview", DevPreviewNoUpgrade, TechPreview, true}, + {"DevPreview includes DevPreview", DevPreviewNoUpgrade, DevPreview, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.featureSet.Includes(tt.stage) + if got != tt.shouldInclude { + t.Errorf("FeatureSet.Includes() = %v, want %v", got, tt.shouldInclude) + } + }) + } +} + +func TestIsGateEnabled(t *testing.T) { + tests := []struct { + name string + gate string + featureSet FeatureSet + want bool + }{ + {"GA gate in Default", "HyperFleetEtcdConfig", Default, true}, + {"GA gate in TechPreview", "HyperFleetEtcdConfig", TechPreviewNoUpgrade, true}, + {"GA gate in DevPreview", "HyperFleetEtcdConfig", DevPreviewNoUpgrade, true}, + + {"TechPreview gate in Default", "HyperFleetAutoScaling", Default, false}, + {"TechPreview gate in TechPreview", "HyperFleetAutoScaling", TechPreviewNoUpgrade, true}, + {"TechPreview gate in DevPreview", "HyperFleetAutoScaling", DevPreviewNoUpgrade, true}, + + {"DevPreview gate in Default", "HyperFleetCustomDNS", Default, false}, + {"DevPreview gate in TechPreview", "HyperFleetCustomDNS", TechPreviewNoUpgrade, false}, + {"DevPreview gate in DevPreview", "HyperFleetCustomDNS", DevPreviewNoUpgrade, true}, + + {"Unknown gate is disabled", "NonExistentGate", DevPreviewNoUpgrade, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsGateEnabled(tt.gate, tt.featureSet) + if got != tt.want { + t.Errorf("IsGateEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGatesForFeatureSet(t *testing.T) { + tests := []struct { + name string + featureSet FeatureSet + wantCount int + }{ + {"Default has 1 gate (GA only)", Default, 1}, + {"TechPreview has 5 gates (GA + TechPreview)", TechPreviewNoUpgrade, 5}, + {"DevPreview has 6 gates (GA + TechPreview + DevPreview)", DevPreviewNoUpgrade, 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gates := GatesForFeatureSet(tt.featureSet) + if len(gates) != tt.wantCount { + t.Errorf("GatesForFeatureSet() returned %d gates, want %d", len(gates), tt.wantCount) + } + }) + } +} + +func TestFilterCRDFields(t *testing.T) { + // This test verifies that feature gates properly filter fields + // Note: Actual field counts depend on the current registry + + defaultFields := FilterCRDFields(Default) + techPreviewFields := FilterCRDFields(TechPreviewNoUpgrade) + devPreviewFields := FilterCRDFields(DevPreviewNoUpgrade) + + // DevPreview should have >= TechPreview should have >= Default + if len(defaultFields) > len(techPreviewFields) { + t.Errorf("Default has more fields (%d) than TechPreview (%d)", + len(defaultFields), len(techPreviewFields)) + } + + if len(techPreviewFields) > len(devPreviewFields) { + t.Errorf("TechPreview has more fields (%d) than DevPreview (%d)", + len(techPreviewFields), len(devPreviewFields)) + } + + t.Logf("Default: %d fields", len(defaultFields)) + t.Logf("TechPreview: %d fields", len(techPreviewFields)) + t.Logf("DevPreview: %d fields", len(devPreviewFields)) +} + +func TestFieldsForFeatureSet(t *testing.T) { + tests := []struct { + name string + featureSet FeatureSet + minFields int // Minimum expected fields (depends on registry) + }{ + {"Default has GA fields only", Default, 1}, + {"TechPreview has GA + TechPreview fields", TechPreviewNoUpgrade, 1}, + {"DevPreview has all fields", DevPreviewNoUpgrade, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := FieldsForFeatureSet(tt.featureSet) + if len(fields) < tt.minFields { + t.Errorf("FieldsForFeatureSet(%s) returned %d fields, want at least %d", + tt.featureSet, len(fields), tt.minFields) + } + + // Verify hierarchy: DevPreview >= TechPreview >= Default + if tt.featureSet == Default { + techFields := FieldsForFeatureSet(TechPreviewNoUpgrade) + if len(fields) > len(techFields) { + t.Errorf("Default has more fields (%d) than TechPreview (%d)", + len(fields), len(techFields)) + } + } + }) + } +} + +func TestSummarizeFeatureSet(t *testing.T) { + summary := SummarizeFeatureSet() + + // Should return a non-empty string + if summary == "" { + t.Error("SummarizeFeatureSet() returned empty string") + } + + // Should mention all three feature sets + if !strings.Contains(summary, "Default") { + t.Error("Summary missing Default feature set") + } + if !strings.Contains(summary, "TechPreviewNoUpgrade") { + t.Error("Summary missing TechPreviewNoUpgrade feature set") + } + if !strings.Contains(summary, "DevPreviewNoUpgrade") { + t.Error("Summary missing DevPreviewNoUpgrade feature set") + } + + // Should mention total fields + if !strings.Contains(summary, "Total fields") { + t.Error("Summary missing 'Total fields' information") + } + + t.Logf("Summary:\n%s", summary) +} + +func TestFeatureStageString(t *testing.T) { + tests := []struct { + stage FeatureStage + want string + }{ + {GA, "GA"}, + {TechPreview, "TechPreview"}, + {DevPreview, "DevPreview"}, + {FeatureStage(999), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := tt.stage.String() + if got != tt.want { + t.Errorf("FeatureStage.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMaxStage(t *testing.T) { + tests := []struct { + name string + featureSet FeatureSet + want FeatureStage + }{ + {"Default max is GA", Default, GA}, + {"TechPreview max is TechPreview", TechPreviewNoUpgrade, TechPreview}, + {"DevPreview max is DevPreview", DevPreviewNoUpgrade, DevPreview}, + {"Unknown defaults to GA", FeatureSet("unknown"), GA}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.featureSet.MaxStage() + if got != tt.want { + t.Errorf("FeatureSet.MaxStage() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hack/api-codegen/pkg/featuregate/registry.go b/hack/api-codegen/pkg/featuregate/registry.go new file mode 100644 index 00000000..1f65122b --- /dev/null +++ b/hack/api-codegen/pkg/featuregate/registry.go @@ -0,0 +1,62 @@ +package featuregate + +// HyperFleetFeatureGates is the registry of all feature gates +// Each gate controls access to specific fields or capabilities +var HyperFleetFeatureGates = map[string]FeatureGateInfo{ + // Example gates - these would be populated based on actual product requirements + + "HyperFleetEtcdConfig": { + Stage: GA, + Description: "Allows customers to configure etcd settings", + }, + + "HyperFleetAutoScaling": { + Stage: TechPreview, + Description: "Enables cluster autoscaling configuration", + }, + + "HyperFleetSecretEncryption": { + Stage: TechPreview, + Description: "Allows customers to configure secret encryption", + }, + + "HyperFleetCustomDNS": { + Stage: DevPreview, + Description: "Enables custom DNS configuration for development/testing", + }, + + "HyperFleetKubeletAdvanced": { + Stage: TechPreview, + Description: "Enables advanced kubelet configuration (serializeImagePulls, registryPullQPS, etc.)", + }, + + "HyperFleetMachineConfig": { + Stage: TechPreview, + Description: "Allows customers to request approved kernel parameters via allowlist", + }, +} + +// IsGateEnabled returns true if the given gate is enabled for the feature set +func IsGateEnabled(gate string, featureSet FeatureSet) bool { + info, exists := HyperFleetFeatureGates[gate] + if !exists { + // Unknown gates are disabled by default + return false + } + + return featureSet.Includes(info.Stage) +} + +// GatesForFeatureSet returns all gates enabled for the given feature set +func GatesForFeatureSet(featureSet FeatureSet) []string { + var gates []string + maxStage := featureSet.MaxStage() + + for gate, info := range HyperFleetFeatureGates { + if info.Stage <= maxStage { + gates = append(gates, gate) + } + } + + return gates +} diff --git a/hack/api-codegen/pkg/featuregate/types.go b/hack/api-codegen/pkg/featuregate/types.go new file mode 100644 index 00000000..ec8e7454 --- /dev/null +++ b/hack/api-codegen/pkg/featuregate/types.go @@ -0,0 +1,74 @@ +package featuregate + +// FeatureStage represents the maturity stage of a feature gate +type FeatureStage int + +const ( + // GA features are generally available to all customers + GA FeatureStage = iota + + // TechPreview features are available to customers who opt into tech preview + // Includes all GA features + TechPreview + + // DevPreview features are available only for development/testing + // Includes all GA and TechPreview features + DevPreview +) + +// String returns the string representation of a FeatureStage +func (s FeatureStage) String() string { + switch s { + case GA: + return "GA" + case TechPreview: + return "TechPreview" + case DevPreview: + return "DevPreview" + default: + return "Unknown" + } +} + +// FeatureGateInfo describes a single feature gate +type FeatureGateInfo struct { + // Stage is the maturity stage of this gate + Stage FeatureStage + + // Description explains what this gate controls + Description string +} + +// FeatureSet represents a collection of feature gates +type FeatureSet string + +const ( + // Default includes only GA features + Default FeatureSet = "Default" + + // TechPreviewNoUpgrade includes GA + TechPreview features + // "NoUpgrade" indicates customers cannot upgrade clusters with these features + TechPreviewNoUpgrade FeatureSet = "TechPreviewNoUpgrade" + + // DevPreviewNoUpgrade includes GA + TechPreview + DevPreview features + DevPreviewNoUpgrade FeatureSet = "DevPreviewNoUpgrade" +) + +// MaxStage returns the maximum feature stage included in this feature set +func (fs FeatureSet) MaxStage() FeatureStage { + switch fs { + case Default: + return GA + case TechPreviewNoUpgrade: + return TechPreview + case DevPreviewNoUpgrade: + return DevPreview + default: + return GA + } +} + +// Includes returns true if this feature set includes the given stage +func (fs FeatureSet) Includes(stage FeatureStage) bool { + return stage <= fs.MaxStage() +} diff --git a/hack/api-codegen/pkg/markers/gated_writemode_test.go b/hack/api-codegen/pkg/markers/gated_writemode_test.go new file mode 100644 index 00000000..b9c1d57b --- /dev/null +++ b/hack/api-codegen/pkg/markers/gated_writemode_test.go @@ -0,0 +1,109 @@ +package markers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExtractMarkers_FeatureGateAwareWriteMode(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "types.go") + + content := `package test + +type Cluster struct { + Spec ClusterSpec ` + "`json:\"spec\"`" + ` +} + +type ClusterSpec struct { + // GA field with customer-tier-based write-mode control + // Standard customers: immutable + // Premium customers (with gate enabled): mutable + // +hyperfleet:write-mode=immutable + // +hyperfleet:validation:FeatureGateAwareWriteMode:featureGate="",writeMode="immutable" + // +hyperfleet:validation:FeatureGateAwareWriteMode:featureGate="PremiumFeature",writeMode="mutable" + ReleaseChannel string ` + "`json:\"releaseChannel\"`" + ` + + // TechPreview field - default service-set, mutable when gated + // +hyperfleet:write-mode=service-set + // +hyperfleet:validation:FeatureGateAwareWriteMode:featureGate="",writeMode="service-set" + // +hyperfleet:validation:FeatureGateAwareWriteMode:featureGate="HyperFleetEtcdConfig",writeMode="mutable" + // +openshift:enable:FeatureGate=HyperFleetEtcdConfig + Etcd string ` + "`json:\"etcd\"`" + ` +} +` + + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + scanner := NewScanner([]string{tmpDir}) + if err := scanner.Scan(); err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Test releaseChannel field + releaseMeta, found := scanner.Registry["spec.releaseChannel"] + if !found { + t.Fatal("spec.releaseChannel not found in registry") + } + + if releaseMeta.WriteMode != Immutable { + t.Errorf("Base WriteMode = %v, want %v", releaseMeta.WriteMode, Immutable) + } + + if len(releaseMeta.FeatureGateAwareWriteModes) != 2 { + t.Fatalf("Expected 2 gated write modes, got %d", len(releaseMeta.FeatureGateAwareWriteModes)) + } + + // Check default mode (empty gate) + if releaseMeta.FeatureGateAwareWriteModes[0].FeatureGate != "" { + t.Errorf("First override FeatureGate = %q, want empty string", releaseMeta.FeatureGateAwareWriteModes[0].FeatureGate) + } + if releaseMeta.FeatureGateAwareWriteModes[0].WriteMode != Immutable { + t.Errorf("First override WriteMode = %v, want %v", releaseMeta.FeatureGateAwareWriteModes[0].WriteMode, Immutable) + } + + // Check premium mode (with gate) + if releaseMeta.FeatureGateAwareWriteModes[1].FeatureGate != "PremiumFeature" { + t.Errorf("Second override FeatureGate = %q, want %q", releaseMeta.FeatureGateAwareWriteModes[1].FeatureGate, "PremiumFeature") + } + if releaseMeta.FeatureGateAwareWriteModes[1].WriteMode != Mutable { + t.Errorf("Second override WriteMode = %v, want %v", releaseMeta.FeatureGateAwareWriteModes[1].WriteMode, Mutable) + } + + // Test etcd field + etcdMeta, found := scanner.Registry["spec.etcd"] + if !found { + t.Fatal("spec.etcd not found in registry") + } + + if etcdMeta.WriteMode != ServiceSet { + t.Errorf("Base WriteMode = %v, want %v", etcdMeta.WriteMode, ServiceSet) + } + + if etcdMeta.FeatureGate != "HyperFleetEtcdConfig" { + t.Errorf("FeatureGate = %q, want %q", etcdMeta.FeatureGate, "HyperFleetEtcdConfig") + } + + if len(etcdMeta.FeatureGateAwareWriteModes) != 2 { + t.Fatalf("Expected 2 gated write modes, got %d", len(etcdMeta.FeatureGateAwareWriteModes)) + } + + // Check default mode (service-set) + if etcdMeta.FeatureGateAwareWriteModes[0].FeatureGate != "" { + t.Errorf("First override FeatureGate = %q, want empty string", etcdMeta.FeatureGateAwareWriteModes[0].FeatureGate) + } + if etcdMeta.FeatureGateAwareWriteModes[0].WriteMode != ServiceSet { + t.Errorf("First override WriteMode = %v, want %v", etcdMeta.FeatureGateAwareWriteModes[0].WriteMode, ServiceSet) + } + + // Check gated mode (mutable when HyperFleetEtcdConfig enabled) + if etcdMeta.FeatureGateAwareWriteModes[1].FeatureGate != "HyperFleetEtcdConfig" { + t.Errorf("Second override FeatureGate = %q, want %q", etcdMeta.FeatureGateAwareWriteModes[1].FeatureGate, "HyperFleetEtcdConfig") + } + if etcdMeta.FeatureGateAwareWriteModes[1].WriteMode != Mutable { + t.Errorf("Second override WriteMode = %v, want %v", etcdMeta.FeatureGateAwareWriteModes[1].WriteMode, Mutable) + } +} diff --git a/hack/api-codegen/pkg/markers/generator.go b/hack/api-codegen/pkg/markers/generator.go new file mode 100644 index 00000000..88a5dbf7 --- /dev/null +++ b/hack/api-codegen/pkg/markers/generator.go @@ -0,0 +1,182 @@ +package markers + +import ( + "bytes" + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "text/template" +) + +const registryTemplate = `// Code generated by marker-scanner. DO NOT EDIT. + +package registry + +// WriteMode defines how a field can be mutated by customers +type WriteMode string + +const ( + // Mutable fields can be set on create and changed on update + Mutable WriteMode = "mutable" + + // Immutable fields can be set on create but cannot be changed on update + Immutable WriteMode = "immutable" + + // ServiceSet fields are set by the platform and cannot be set by customers + ServiceSet WriteMode = "service-set" +) + +// FeatureGateWriteMode represents a write-mode override for a specific feature gate +type FeatureGateWriteMode struct { + // FeatureGate is the gate that enables this write-mode (empty string = default/no gates enabled) + FeatureGate string + + // WriteMode is the effective write-mode when this gate condition matches + WriteMode WriteMode +} + +// FieldMeta contains metadata for a single field +type FieldMeta struct { + // FieldPath is the JSON path to the field (e.g., "spec.name") + FieldPath string + + // WriteMode controls customer mutability + WriteMode WriteMode + + // FeatureGate is the gate required to use this field (empty if no gate required) + FeatureGate string + + // Hidden indicates if the field is excluded from OpenAPI + Hidden bool + + // FeatureGateAwareWriteModes allows write-mode to vary based on enabled feature gates + FeatureGateAwareWriteModes []FeatureGateWriteMode +} + +// FieldRegistry maps field paths to their metadata +var FieldRegistry = map[string]FieldMeta{ +{{- range .Fields }} + "{{ .FieldPath }}": { + FieldPath: "{{ .FieldPath }}", + {{- if .WriteMode }} + WriteMode: {{ .WriteMode }}, + {{- end }} + {{- if .FeatureGate }} + FeatureGate: "{{ .FeatureGate }}", + {{- end }} + {{- if .Hidden }} + Hidden: true, + {{- end }} + {{- if .GatedWriteModes }} + FeatureGateAwareWriteModes: []FeatureGateWriteMode{ + {{- range .GatedWriteModes }} + {FeatureGate: "{{ .FeatureGate }}", WriteMode: {{ .WriteMode }}}, + {{- end }} + }, + {{- end }} + }, +{{- end }} +} +` + +type templateData struct { + Fields []templateField +} + +type templateField struct { + FieldPath string + WriteMode string + FeatureGate string + Hidden bool + GatedWriteModes []templateGatedWriteMode +} + +type templateGatedWriteMode struct { + FeatureGate string + WriteMode string +} + +// Generate creates the registry Go file from collected metadata +func (s *MarkerScanner) Generate(outputFile string) error { + // Ensure output directory exists + dir := filepath.Dir(outputFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // Prepare template data + data := templateData{ + Fields: make([]templateField, 0, len(s.Registry)), + } + + // Sort fields for deterministic output + var paths []string + for path := range s.Registry { + paths = append(paths, path) + } + sort.Strings(paths) + + for _, path := range paths { + meta := s.Registry[path] + field := templateField{ + FieldPath: meta.FieldPath, + FeatureGate: meta.FeatureGate, + Hidden: meta.Hidden, + } + + // Convert WriteMode to const reference + switch meta.WriteMode { + case Mutable: + field.WriteMode = "Mutable" + case Immutable: + field.WriteMode = "Immutable" + case ServiceSet: + field.WriteMode = "ServiceSet" + } + + // Convert FeatureGateAwareWriteModes + for _, gated := range meta.FeatureGateAwareWriteModes { + var writeModeStr string + switch gated.WriteMode { + case Mutable: + writeModeStr = "Mutable" + case Immutable: + writeModeStr = "Immutable" + case ServiceSet: + writeModeStr = "ServiceSet" + } + field.GatedWriteModes = append(field.GatedWriteModes, templateGatedWriteMode{ + FeatureGate: gated.FeatureGate, + WriteMode: writeModeStr, + }) + } + + data.Fields = append(data.Fields, field) + } + + // Execute template + tmpl, err := template.New("registry").Parse(registryTemplate) + if err != nil { + return fmt.Errorf("parsing template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("executing template: %w", err) + } + + // Format the generated code + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("formatting generated code: %w", err) + } + + // Write to file + if err := os.WriteFile(outputFile, formatted, 0644); err != nil { + return fmt.Errorf("writing output file: %w", err) + } + + return nil +} diff --git a/hack/api-codegen/pkg/markers/json.go b/hack/api-codegen/pkg/markers/json.go new file mode 100644 index 00000000..e118d94a --- /dev/null +++ b/hack/api-codegen/pkg/markers/json.go @@ -0,0 +1,97 @@ +package markers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// GenerateJSON creates a JSON file from the field registry for use by other tools +func (s *MarkerScanner) GenerateJSON(outputFile string) error { + // Ensure output directory exists + dir := filepath.Dir(outputFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // Convert registry to sorted slice for deterministic output + type jsonField struct { + FieldPath string `json:"fieldPath"` + WriteMode string `json:"writeMode,omitempty"` + FeatureGate string `json:"featureGate,omitempty"` + Hidden bool `json:"hidden,omitempty"` + FeatureGateAwareWriteModes []FeatureGateWriteMode `json:"featureGateAwareWriteModes,omitempty"` + } + + var fields []jsonField + var paths []string + for path := range s.Registry { + paths = append(paths, path) + } + sort.Strings(paths) + + for _, path := range paths { + meta := s.Registry[path] + field := jsonField{ + FieldPath: meta.FieldPath, + WriteMode: string(meta.WriteMode), + FeatureGate: meta.FeatureGate, + Hidden: meta.Hidden, + FeatureGateAwareWriteModes: meta.FeatureGateAwareWriteModes, + } + fields = append(fields, field) + } + + // Marshal to JSON with indentation + data, err := json.MarshalIndent(fields, "", " ") + if err != nil { + return fmt.Errorf("marshaling JSON: %w", err) + } + + // Write to file + if err := os.WriteFile(outputFile, data, 0644); err != nil { + return fmt.Errorf("writing output file: %w", err) + } + + return nil +} + +// LoadRegistryFromJSON loads a field registry from a JSON file +func LoadRegistryFromJSON(jsonFile string) (FieldRegistry, error) { + data, err := os.ReadFile(jsonFile) + if err != nil { + return nil, fmt.Errorf("reading JSON file: %w", err) + } + return LoadRegistryFromJSONBytes(data) +} + +// LoadRegistryFromJSONBytes loads a field registry from raw JSON bytes +func LoadRegistryFromJSONBytes(data []byte) (FieldRegistry, error) { + type jsonField struct { + FieldPath string `json:"fieldPath"` + WriteMode string `json:"writeMode,omitempty"` + FeatureGate string `json:"featureGate,omitempty"` + Hidden bool `json:"hidden,omitempty"` + FeatureGateAwareWriteModes []FeatureGateWriteMode `json:"featureGateAwareWriteModes,omitempty"` + } + + var fields []jsonField + if err := json.Unmarshal(data, &fields); err != nil { + return nil, fmt.Errorf("unmarshaling JSON: %w", err) + } + + registry := make(FieldRegistry) + for _, field := range fields { + registry[field.FieldPath] = FieldMeta{ + FieldPath: field.FieldPath, + WriteMode: WriteMode(field.WriteMode), + FeatureGate: field.FeatureGate, + Hidden: field.Hidden, + FeatureGateAwareWriteModes: field.FeatureGateAwareWriteModes, + } + } + + return registry, nil +} diff --git a/hack/api-codegen/pkg/markers/json_test.go b/hack/api-codegen/pkg/markers/json_test.go new file mode 100644 index 00000000..20b19c9e --- /dev/null +++ b/hack/api-codegen/pkg/markers/json_test.go @@ -0,0 +1,140 @@ +package markers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadRegistryFromJSON_NonexistentFile(t *testing.T) { + _, err := LoadRegistryFromJSON("/nonexistent/file.json") + if err == nil { + t.Error("LoadRegistryFromJSON() with nonexistent file should return error") + } +} + +func TestLoadRegistryFromJSON_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + invalidFile := filepath.Join(tmpDir, "invalid.json") + + // Write invalid JSON + err := os.WriteFile(invalidFile, []byte("not valid json {"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = LoadRegistryFromJSON(invalidFile) + if err == nil { + t.Error("LoadRegistryFromJSON() with invalid JSON should return error") + } +} + +func TestLoadRegistryFromJSON_ValidFile(t *testing.T) { + tmpDir := t.TempDir() + validFile := filepath.Join(tmpDir, "valid.json") + + // Write valid JSON (array format) + jsonContent := `[ + { + "fieldPath": "spec.name", + "writeMode": "immutable", + "hidden": false + }, + { + "fieldPath": "spec.accountId", + "writeMode": "service-set", + "hidden": true + } + ]` + + err := os.WriteFile(validFile, []byte(jsonContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + loaded, err := LoadRegistryFromJSON(validFile) + if err != nil { + t.Fatalf("LoadRegistryFromJSON() error = %v", err) + } + + if len(loaded) != 2 { + t.Errorf("LoadRegistryFromJSON() loaded %d fields, want 2", len(loaded)) + } + + // Check specific field + if meta, exists := loaded["spec.name"]; exists { + if string(meta.WriteMode) != "immutable" { + t.Errorf("spec.name WriteMode = %s, want immutable", meta.WriteMode) + } + if meta.Hidden { + t.Error("spec.name should not be hidden") + } + } else { + t.Error("spec.name not found in loaded registry") + } + + // Check hidden field + if meta, exists := loaded["spec.accountId"]; exists { + if string(meta.WriteMode) != "service-set" { + t.Errorf("spec.accountId WriteMode = %s, want service-set", meta.WriteMode) + } + if !meta.Hidden { + t.Error("spec.accountId should be hidden") + } + } else { + t.Error("spec.accountId not found in loaded registry") + } +} + +func TestLoadRegistryFromJSON_EmptyArray(t *testing.T) { + tmpDir := t.TempDir() + emptyFile := filepath.Join(tmpDir, "empty.json") + + // Write empty JSON array + err := os.WriteFile(emptyFile, []byte("[]"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + loaded, err := LoadRegistryFromJSON(emptyFile) + if err != nil { + t.Fatalf("LoadRegistryFromJSON() error = %v", err) + } + + if len(loaded) != 0 { + t.Errorf("Expected empty registry, got %d fields", len(loaded)) + } +} + +func TestLoadRegistryFromJSON_WithFeatureGates(t *testing.T) { + tmpDir := t.TempDir() + gatedFile := filepath.Join(tmpDir, "gated.json") + + // Write JSON with feature-gated field (array format) + jsonContent := `[ + { + "fieldPath": "spec.etcd", + "writeMode": "mutable", + "featureGate": "HyperFleetEtcdConfig", + "hidden": false + } + ]` + + err := os.WriteFile(gatedFile, []byte(jsonContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + loaded, err := LoadRegistryFromJSON(gatedFile) + if err != nil { + t.Fatalf("LoadRegistryFromJSON() error = %v", err) + } + + if meta, exists := loaded["spec.etcd"]; exists { + if meta.FeatureGate != "HyperFleetEtcdConfig" { + t.Errorf("spec.etcd FeatureGate = %s, want HyperFleetEtcdConfig", meta.FeatureGate) + } + } else { + t.Error("spec.etcd not found in loaded registry") + } +} diff --git a/hack/api-codegen/pkg/markers/scanner.go b/hack/api-codegen/pkg/markers/scanner.go new file mode 100644 index 00000000..8264f9c6 --- /dev/null +++ b/hack/api-codegen/pkg/markers/scanner.go @@ -0,0 +1,308 @@ +package markers + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "regexp" + "sort" + "strings" +) + +var ( + // Marker patterns + openapiGenPattern = regexp.MustCompile(`\+k8s:openapi-gen=false`) + writeModePattern = regexp.MustCompile(`\+hyperfleet:write-mode=(mutable|immutable|service-set)`) + featureGatePattern = regexp.MustCompile(`\+openshift:enable:FeatureGate=(\w+)`) + featureGateAwareWriteModePattern = regexp.MustCompile(`\+hyperfleet:validation:FeatureGateAwareWriteMode:featureGate="([^"]*)",writeMode="(mutable|immutable|service-set)"`) +) + +// NewScanner creates a new marker scanner +func NewScanner(inputDirs []string) *MarkerScanner { + return &MarkerScanner{ + InputDirs: inputDirs, + Registry: make(FieldRegistry), + typeCache: make(map[string]*ast.StructType), + } +} + +// Scan walks the input directories and extracts marker metadata +func (s *MarkerScanner) Scan() error { + for _, dir := range s.InputDirs { + if err := s.scanDir(dir); err != nil { + return fmt.Errorf("scanning directory %s: %w", dir, err) + } + } + return nil +} + +// scanDir processes all Go files in a directory +func (s *MarkerScanner) scanDir(dir string) error { + fset := token.NewFileSet() + + //nolint:staticcheck // ParseDir is sufficient for our use case of scanning single directories + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + // Skip test files and generated files + name := fi.Name() + return !strings.HasSuffix(name, "_test.go") && + !strings.HasPrefix(name, "zz_generated") + }, parser.ParseComments) + + if err != nil { + return fmt.Errorf("parsing directory: %w", err) + } + + // Scope the type cache to this directory so same-named types in + // different packages don't collide. + dirCache := make(map[string]*ast.StructType) + + // First pass: cache all struct types across all files in this package + for _, pkg := range pkgs { + for _, file := range pkg.Files { + ast.Inspect(file, func(n ast.Node) bool { + typeSpec, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return true + } + dirCache[typeSpec.Name.Name] = structType + return true + }) + } + } + + // Install this directory's cache for nested-type resolution + s.typeCache = dirCache + + // Second pass: process root types in sorted order for deterministic registry output + var roots []string + for typeName := range dirCache { + if isRootType(typeName) { + roots = append(roots, typeName) + } + } + sort.Strings(roots) + + for _, typeName := range roots { + visited := make(map[string]bool) + visited[typeName] = true + s.processStruct(typeName, dirCache[typeName], rootTypes[typeName], visited) + } + + return nil +} + +// rootTypes maps type names to the parentPath prefix the scanner should use +// when walking them as top-level entry points. CRD resource types start with +// an empty prefix (their own Spec/Status fields carry the "spec."/"status." +// segment). Passthrough types reference external packages that the scanner +// cannot follow, so they are listed here with an explicit prefix so their +// fields are emitted in canonical spec.-prefixed form. Sub-structs like +// KubeletConfig or ClusterConfiguration are reached through field traversal +// and must NOT appear here — walking them independently would emit bare +// duplicate paths. +var rootTypes = map[string]string{ + "Cluster": "", + "NodePool": "", + "ManagementCluster": "", + "Manifest": "", + "Placement": "", + "HostedClusterSpecPassthrough": "spec.hostedCluster", + "NodePoolSpecPassthrough": "spec.nodePool", +} + +func isRootType(typeName string) bool { + _, ok := rootTypes[typeName] + return ok +} + +// processStruct walks struct fields and extracts markers +func (s *MarkerScanner) processStruct(_ string, structType *ast.StructType, parentPath string, visited map[string]bool) { + for _, field := range structType.Fields.List { + s.processField(field, parentPath, visited) + } +} + +// processField extracts markers from a single field +func (s *MarkerScanner) processField(field *ast.Field, parentPath string, visited map[string]bool) { + // Get JSON tag to determine field path + jsonName := getJSONName(field) + if jsonName == "-" { + return + } + + // Anonymous embedded fields have no JSON name; traverse their nested + // type under the current parentPath so promoted fields are registered. + if jsonName == "" { + s.processNestedType(field.Type, parentPath, visited) + return + } + + // Build full field path + var fieldPath string + if parentPath == "" { + fieldPath = jsonName + } else { + fieldPath = parentPath + "." + jsonName + } + + // Extract markers from comments + meta := s.extractMarkers(field, fieldPath) + if meta != nil { + s.Registry[fieldPath] = *meta + } + + // Recursively process nested structs + s.processNestedType(field.Type, fieldPath, visited) +} + +// processNestedType recursively handles nested struct types. +// visited tracks named types already being traversed to prevent infinite +// recursion on self-referential or mutually recursive structs. +func (s *MarkerScanner) processNestedType(expr ast.Expr, fieldPath string, visited map[string]bool) { + switch t := expr.(type) { + case *ast.StructType: + // Inline struct — no named type to track + s.processStruct("", t, fieldPath, visited) + case *ast.StarExpr: + // Pointer to type + s.processNestedType(t.X, fieldPath, visited) + case *ast.Ident: + // Named type - skip if already visited in this traversal + if visited[t.Name] { + return + } + if structType, ok := s.typeCache[t.Name]; ok { + visited[t.Name] = true + s.processStruct(t.Name, structType, fieldPath, visited) + delete(visited, t.Name) + } + case *ast.SelectorExpr: + // External type (e.g., metav1.Time) - skip + case *ast.ArrayType: + // Array/slice - process element type + s.processNestedType(t.Elt, fieldPath, visited) + case *ast.MapType: + // Map - process value type + s.processNestedType(t.Value, fieldPath, visited) + } +} + +// extractMarkers parses comment markers and creates FieldMeta +func (s *MarkerScanner) extractMarkers(field *ast.Field, fieldPath string) *FieldMeta { + if field.Doc == nil { + return nil + } + + comments := field.Doc.Text() + + meta := &FieldMeta{ + FieldPath: fieldPath, + } + + // Check for openapi-gen=false (field is hidden) + if openapiGenPattern.MatchString(comments) { + meta.Hidden = true + } + + // Extract write mode + if matches := writeModePattern.FindStringSubmatch(comments); len(matches) > 1 { + meta.WriteMode = WriteMode(matches[1]) + } + + // Extract feature gate + if matches := featureGatePattern.FindStringSubmatch(comments); len(matches) > 1 { + meta.FeatureGate = matches[1] + } + + // Extract feature-gate-aware write-modes + var gatedModes []FeatureGateWriteMode + for _, match := range featureGateAwareWriteModePattern.FindAllStringSubmatch(comments, -1) { + featureGate := match[1] // Empty string or gate name + mode := WriteMode(match[2]) + gatedModes = append(gatedModes, FeatureGateWriteMode{ + FeatureGate: featureGate, + WriteMode: mode, + }) + } + + if len(gatedModes) > 0 { + meta.FeatureGateAwareWriteModes = gatedModes + } + + // Only include in registry if at least one marker was found + if meta.Hidden || meta.WriteMode != "" || meta.FeatureGate != "" || len(meta.FeatureGateAwareWriteModes) > 0 { + return meta + } + + return nil +} + +// getJSONName extracts the JSON field name from struct tags +func getJSONName(field *ast.Field) string { + if field.Tag == nil { + return "" + } + + tag := field.Tag.Value + // Remove backticks + tag = strings.Trim(tag, "`") + + // Parse json tag + jsonTag := parseStructTag(tag, "json") + if jsonTag == "" { + return "" + } + + // Handle "name,omitempty" or "name" format + parts := strings.Split(jsonTag, ",") + return parts[0] +} + +// parseStructTag extracts a specific tag value from struct tag string +func parseStructTag(tag, key string) string { + // Simple tag parser - handles: `json:"name,omitempty" yaml:"name"` + parts := strings.Fields(tag) + prefix := key + `:"` + + for _, part := range parts { + if strings.HasPrefix(part, prefix) { + value := strings.TrimPrefix(part, prefix) + value = strings.TrimSuffix(value, `"`) + return value + } + } + + return "" +} + +// Validate checks that all fields in the registry have required markers +func (r FieldRegistry) Validate() error { + var errors []string + + for path, meta := range r { + // All visible fields must have a write mode + if !meta.Hidden && meta.WriteMode == "" { + errors = append(errors, fmt.Sprintf("field %s is missing +hyperfleet:write-mode marker", path)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("validation failed:\n %s", strings.Join(errors, "\n ")) + } + + return nil +} + +// ValidateAllFields checks that ALL struct fields (not just those with markers) meet requirements +// This is more strict and should be used in CI +func (s *MarkerScanner) ValidateAllFields() error { + // This would require re-scanning and checking all fields, not just those with markers + // For now, just validate what's in the registry + return s.Registry.Validate() +} diff --git a/hack/api-codegen/pkg/markers/scanner_test.go b/hack/api-codegen/pkg/markers/scanner_test.go new file mode 100644 index 00000000..4374bfb4 --- /dev/null +++ b/hack/api-codegen/pkg/markers/scanner_test.go @@ -0,0 +1,228 @@ +package markers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMarkerExtraction(t *testing.T) { + // Create a temporary directory with test files + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "types.go") + content := `package test + +// Root type - scanner starts here +type Cluster struct { + Spec ClusterSpec ` + "`json:\"spec\"`" + ` +} + +type ClusterSpec struct { + // Customer can set and change + // +hyperfleet:write-mode=mutable + DeleteProtection *bool ` + "`json:\"deleteProtection,omitempty\"`" + ` + + // Customer sets on create, cannot change + // +hyperfleet:write-mode=immutable + Name string ` + "`json:\"name\"`" + ` + + // Platform sets, customer cannot see + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + AccountID string ` + "`json:\"accountId\"`" + ` + + // Gated field + // +openshift:enable:FeatureGate=HyperFleetEtcdConfig + // +hyperfleet:write-mode=immutable + Etcd *EtcdSpec ` + "`json:\"etcd,omitempty\"`" + ` +} + +type EtcdSpec struct { + // +hyperfleet:write-mode=immutable + ManagementType string ` + "`json:\"managementType\"`" + ` +} +` + + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Create scanner and scan + scanner := NewScanner([]string{tmpDir}) + if err := scanner.Scan(); err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Verify results - paths are now fully qualified from root type + tests := []struct { + fieldPath string + writeMode WriteMode + featureGate string + hidden bool + }{ + {"spec.deleteProtection", Mutable, "", false}, + {"spec.name", Immutable, "", false}, + {"spec.accountId", ServiceSet, "", true}, + {"spec.etcd", Immutable, "HyperFleetEtcdConfig", false}, + {"spec.etcd.managementType", Immutable, "", false}, + } + + for _, tt := range tests { + t.Run(tt.fieldPath, func(t *testing.T) { + meta, found := scanner.Registry[tt.fieldPath] + if !found { + t.Fatalf("Field %s not found in registry", tt.fieldPath) + } + + if meta.WriteMode != tt.writeMode { + t.Errorf("WriteMode = %v, want %v", meta.WriteMode, tt.writeMode) + } + + if meta.FeatureGate != tt.featureGate { + t.Errorf("FeatureGate = %v, want %v", meta.FeatureGate, tt.featureGate) + } + + if meta.Hidden != tt.hidden { + t.Errorf("Hidden = %v, want %v", meta.Hidden, tt.hidden) + } + }) + } +} + +func TestProcessField_EmbeddedWithoutJSONTag(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "types.go") + + content := `package test + +type Cluster struct { + Spec ClusterSpec ` + "`json:\"spec\"`" + ` +} + +type ClusterSpec struct { + // Anonymous embedded struct — no JSON tag + CommonFields +} + +type CommonFields struct { + // +hyperfleet:write-mode=mutable + DisplayName string ` + "`json:\"displayName\"`" + ` +} +` + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + scanner := NewScanner([]string{tmpDir}) + if err := scanner.Scan(); err != nil { + t.Fatalf("Scan failed: %v", err) + } + + meta, found := scanner.Registry["spec.displayName"] + if !found { + t.Fatal("spec.displayName not found — embedded field was not traversed") + } + if meta.WriteMode != Mutable { + t.Errorf("WriteMode = %v, want %v", meta.WriteMode, Mutable) + } +} + +func TestProcessField_ExplicitlyIgnored(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "types.go") + + content := `package test + +type Cluster struct { + Spec ClusterSpec ` + "`json:\"spec\"`" + ` +} + +type ClusterSpec struct { + // +hyperfleet:write-mode=mutable + Ignored string ` + "`json:\"-\"`" + ` +} +` + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + scanner := NewScanner([]string{tmpDir}) + if err := scanner.Scan(); err != nil { + t.Fatalf("Scan failed: %v", err) + } + + if _, found := scanner.Registry["spec.Ignored"]; found { + t.Error("json:\"-\" field should not be registered") + } + if _, found := scanner.Registry["Ignored"]; found { + t.Error("json:\"-\" field should not be registered under bare name") + } +} + +func TestValidation(t *testing.T) { + tests := []struct { + name string + content string + wantErr bool + }{ + { + name: "valid - all visible fields have write mode", + content: `package test +type Cluster struct { + Spec Spec ` + "`json:\"spec\"`" + ` +} +type Spec struct { + // +hyperfleet:write-mode=mutable + Field string ` + "`json:\"field\"`" + ` +}`, + wantErr: false, + }, + { + name: "invalid - field has marker but missing write mode", + content: `package test +type Cluster struct { + Spec Spec ` + "`json:\"spec\"`" + ` +} +type Spec struct { + // +openshift:enable:FeatureGate=Test + Field string ` + "`json:\"field\"`" + ` +}`, + wantErr: true, + }, + { + name: "valid - hidden field without write mode is OK", + content: `package test +type Cluster struct { + Spec Spec ` + "`json:\"spec\"`" + ` +} +type Spec struct { + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Field string ` + "`json:\"field\"`" + ` +}`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "types.go") + + if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + scanner := NewScanner([]string{tmpDir}) + if err := scanner.Scan(); err != nil { + t.Fatalf("Scan failed: %v", err) + } + + err := scanner.Registry.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/hack/api-codegen/pkg/markers/types.go b/hack/api-codegen/pkg/markers/types.go new file mode 100644 index 00000000..27dc4062 --- /dev/null +++ b/hack/api-codegen/pkg/markers/types.go @@ -0,0 +1,60 @@ +package markers + +import "go/ast" + +// WriteMode defines how a field can be mutated by customers +type WriteMode string + +const ( + // Mutable fields can be set on create and changed on update + Mutable WriteMode = "mutable" + + // Immutable fields can be set on create but cannot be changed on update + Immutable WriteMode = "immutable" + + // ServiceSet fields are set by the platform and cannot be set by customers + ServiceSet WriteMode = "service-set" +) + +// FeatureGateWriteMode represents a write-mode override for a specific feature gate +type FeatureGateWriteMode struct { + // FeatureGate is the gate that enables this write-mode (empty string = default/no gates enabled) + FeatureGate string `json:"featureGate"` + + // WriteMode is the effective write-mode when this gate condition matches + WriteMode WriteMode `json:"writeMode"` +} + +// FieldMeta contains metadata extracted from Go markers for a single field +type FieldMeta struct { + // FieldPath is the JSON path to the field (e.g., "spec.name", "spec.hostedCluster.release") + FieldPath string + + // WriteMode controls customer mutability + WriteMode WriteMode + + // FeatureGate is the gate required to use this field (empty if no gate required) + FeatureGate string + + // Hidden indicates if the field is excluded from OpenAPI (+k8s:openapi-gen=false) + Hidden bool + + // FeatureGateAwareWriteModes allows write-mode to vary based on enabled feature gates + // Empty FeatureGate in an entry means "default" (when no gates are enabled) + FeatureGateAwareWriteModes []FeatureGateWriteMode `json:"featureGateAwareWriteModes,omitempty"` +} + +// FieldRegistry is a map from field path to its metadata +type FieldRegistry map[string]FieldMeta + +// MarkerScanner extracts markers from Go source files +type MarkerScanner struct { + // InputDirs are the directories to scan for Go files + InputDirs []string + + // Registry is the collected field metadata + Registry FieldRegistry + + // typeCache maps type names to their struct definitions + typeCache map[string]*ast.StructType +} diff --git a/hack/api-codegen/pkg/openapi/generator.go b/hack/api-codegen/pkg/openapi/generator.go new file mode 100644 index 00000000..2dab9ade --- /dev/null +++ b/hack/api-codegen/pkg/openapi/generator.go @@ -0,0 +1,398 @@ +package openapi + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// Generate creates an OpenAPI schema from the specified Go types +func (g *Generator) Generate() error { + if len(g.InputDirs) == 0 { + return g.generatePOC() + } + + // Parse all Go files in input directories + definitions, typeNames, err := g.scanTypes() + if err != nil { + return fmt.Errorf("scanning types: %w", err) + } + + // Store type names for $ref generation + g.knownTypes = typeNames + + // Create OpenAPI schema + swagger := &spec.Swagger{ + SwaggerProps: spec.SwaggerProps{ + Swagger: "2.0", + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: g.Title, + Version: g.Version, + Description: "OpenAPI schema for " + g.Title + " generated from Go types with markers\n\nFields marked with +k8s:openapi-gen=false are excluded from this schema.", + }, + }, + Paths: &spec.Paths{Paths: make(map[string]spec.PathItem)}, + Definitions: definitions, + }, + } + + // Serialize to JSON + data, err := json.MarshalIndent(swagger, "", " ") + if err != nil { + return fmt.Errorf("marshaling OpenAPI schema: %w", err) + } + + // Ensure output directory exists + outputDir := filepath.Dir(g.OutputFile) + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // Write to file + if err := os.WriteFile(g.OutputFile, data, 0644); err != nil { + return fmt.Errorf("writing OpenAPI schema: %w", err) + } + + return nil +} + +// generatePOC generates a minimal POC schema (legacy behavior) +func (g *Generator) generatePOC() error { + swagger := &spec.Swagger{ + SwaggerProps: spec.SwaggerProps{ + Swagger: "2.0", + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: g.Title, + Version: g.Version, + Description: "OpenAPI schema for HyperFleet API generated from Go types with markers\n\nFields marked with +k8s:openapi-gen=false are excluded from this schema.", + }, + }, + Paths: &spec.Paths{Paths: make(map[string]spec.PathItem)}, + Definitions: make(spec.Definitions), + }, + } + + data, err := json.MarshalIndent(swagger, "", " ") + if err != nil { + return fmt.Errorf("marshaling OpenAPI schema: %w", err) + } + + if err := os.WriteFile(g.OutputFile, data, 0644); err != nil { + return fmt.Errorf("writing OpenAPI schema: %w", err) + } + + return nil +} + +// scanTypes scans Go source files and generates OpenAPI definitions +func (g *Generator) scanTypes() (spec.Definitions, map[string]bool, error) { + definitions := make(spec.Definitions) + typeNames := make(map[string]bool) + + // First pass: collect all type names and AST nodes + type typeInfo struct { + name string + structType *ast.StructType + doc *ast.CommentGroup + } + var allTypes []typeInfo + + for _, dir := range g.InputDirs { + fset := token.NewFileSet() + + // Parse all Go files in directory + //nolint:staticcheck // ParseDir is sufficient for our use case + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + name := fi.Name() + // Skip test files + return !strings.HasSuffix(name, "_test.go") && + !strings.HasPrefix(name, "zz_generated") + }, parser.ParseComments) + + if err != nil { + return nil, nil, fmt.Errorf("parsing directory %s: %w", dir, err) + } + + // Collect all type names first + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok || !typeSpec.Name.IsExported() { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + typeName := typeSpec.Name.Name + typeNames[typeName] = true + allTypes = append(allTypes, typeInfo{ + name: typeName, + structType: structType, + doc: genDecl.Doc, + }) + } + } + } + } + } + + // Store known types BEFORE generating schemas + g.knownTypes = typeNames + + // Second pass: generate schemas with $ref support + for _, ti := range allTypes { + schema := g.generateSchema(ti.structType, ti.doc) + if schema != nil { + definitions[ti.name] = *schema + } + } + + return definitions, typeNames, nil +} + +// generateSchema generates an OpenAPI schema for a struct type +func (g *Generator) generateSchema(structType *ast.StructType, doc *ast.CommentGroup) *spec.Schema { + schema := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: make(map[string]spec.Schema), + }, + } + + // Add type description from doc comment + if doc != nil { + schema.Description = strings.TrimSpace(doc.Text()) + } + + var required []string + + // Process each field + for _, field := range structType.Fields.List { + // Skip embedded fields (inline types) + if len(field.Names) == 0 { + // Handle embedded TypeMeta, ObjectMeta, etc. + continue + } + + for _, name := range field.Names { + // Skip unexported fields + if !name.IsExported() { + continue + } + + // Check for +k8s:openapi-gen=false marker + if g.isHidden(field) { + continue + } + + // Extract JSON tag + jsonName := g.extractJSONTag(field) + if jsonName == "" || jsonName == "-" { + continue + } + + // Generate field schema + fieldSchema := g.generateFieldSchema(field) + + // Add field description + if field.Doc != nil { + fieldSchema.Description = strings.TrimSpace(field.Doc.Text()) + } else if field.Comment != nil { + fieldSchema.Description = strings.TrimSpace(field.Comment.Text()) + } + + // Check if field is required (no omitempty tag) + if g.isRequired(field) { + required = append(required, jsonName) + } + + schema.Properties[jsonName] = fieldSchema + } + } + + // Sort required fields for consistent output + sort.Strings(required) + schema.Required = required + + return schema +} + +// generateFieldSchema generates a schema for a single field +func (g *Generator) generateFieldSchema(field *ast.Field) spec.Schema { + schema := spec.Schema{} + + typeStr := g.exprToString(field.Type) + + // Handle pointers + typeStr = strings.TrimPrefix(typeStr, "*") + + // Handle basic types + switch typeStr { + case "string": + schema.Type = []string{"string"} + case "bool": + schema.Type = []string{"boolean"} + case "int", "int32", "int64": + schema.Type = []string{"integer"} + if typeStr == "int64" { + schema.Format = "int64" + } else { + schema.Format = "int32" + } + case "float32", "float64": + schema.Type = []string{"number"} + if typeStr == "float64" { + schema.Format = "double" + } else { + schema.Format = "float" + } + default: + // Handle arrays + if strings.HasPrefix(typeStr, "[]") { + schema.Type = []string{"array"} + elemType := strings.TrimPrefix(typeStr, "[]") + elemType = strings.TrimPrefix(elemType, "*") + schema.Items = &spec.SchemaOrArray{ + Schema: g.resolveTypeSchema(elemType), + } + } else if strings.HasPrefix(typeStr, "map[") { + // Handle maps — extract value type after the closing ] + schema.Type = []string{"object"} + valType := typeStr + if idx := strings.Index(typeStr, "]"); idx != -1 { + valType = typeStr[idx+1:] + } + valType = strings.TrimPrefix(valType, "*") + schema.AdditionalProperties = &spec.SchemaOrBool{ + Allows: true, + Schema: g.resolveTypeSchema(valType), + } + } else { + schema = *g.resolveTypeSchema(typeStr) + } + } + + return schema +} + +// isHidden checks if a field has +k8s:openapi-gen=false marker +func (g *Generator) isHidden(field *ast.Field) bool { + if field.Doc != nil { + text := field.Doc.Text() + if strings.Contains(text, "+k8s:openapi-gen=false") { + return true + } + } + if field.Comment != nil { + text := field.Comment.Text() + if strings.Contains(text, "+k8s:openapi-gen=false") { + return true + } + } + return false +} + +// extractJSONTag extracts the JSON field name from struct tags +func (g *Generator) extractJSONTag(field *ast.Field) string { + if field.Tag == nil { + return "" + } + + tag := strings.Trim(field.Tag.Value, "`") + parts := strings.Fields(tag) + + for _, part := range parts { + if strings.HasPrefix(part, "json:") { + jsonTag := strings.TrimPrefix(part, "json:") + jsonTag = strings.Trim(jsonTag, "\"") + // Split on comma to handle omitempty + parts := strings.Split(jsonTag, ",") + return parts[0] + } + } + + return "" +} + +// isRequired checks if a field is required (no omitempty tag) +func (g *Generator) isRequired(field *ast.Field) bool { + if field.Tag == nil { + return false + } + + tag := strings.Trim(field.Tag.Value, "`") + parts := strings.Fields(tag) + + for _, part := range parts { + if strings.HasPrefix(part, "json:") { + jsonTag := strings.TrimPrefix(part, "json:") + jsonTag = strings.Trim(jsonTag, "\"") + // Check if omitempty is present + return !strings.Contains(jsonTag, "omitempty") + } + } + + return false +} + +// exprToString converts an AST expression to a string +func (g *Generator) exprToString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return "*" + g.exprToString(t.X) + case *ast.ArrayType: + return "[]" + g.exprToString(t.Elt) + case *ast.MapType: + return "map[" + g.exprToString(t.Key) + "]" + g.exprToString(t.Value) + case *ast.SelectorExpr: + return g.exprToString(t.X) + "." + t.Sel.Name + default: + return "interface{}" + } +} + +// resolveTypeSchema returns a schema for a Go type name, using $ref for known +// definitions and primitive schemas for basic types. +func (g *Generator) resolveTypeSchema(goType string) *spec.Schema { + switch goType { + case "string": + return &spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"string"}}} + case "bool": + return &spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"boolean"}}} + case "int", "int32", "int64": + return &spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"integer"}}} + case "float32", "float64": + return &spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"number"}}} + default: + typeName := goType + if idx := strings.LastIndex(goType, "."); idx != -1 { + typeName = goType[idx+1:] + } + if g.knownTypes != nil && g.knownTypes[typeName] { + return &spec.Schema{SchemaProps: spec.SchemaProps{Ref: spec.MustCreateRef("#/definitions/" + typeName)}} + } + return &spec.Schema{SchemaProps: spec.SchemaProps{Type: []string{"object"}}} + } +} diff --git a/hack/api-codegen/pkg/openapi/generator_test.go b/hack/api-codegen/pkg/openapi/generator_test.go new file mode 100644 index 00000000..2a503c45 --- /dev/null +++ b/hack/api-codegen/pkg/openapi/generator_test.go @@ -0,0 +1,54 @@ +package openapi + +import ( + "encoding/json" + "os" + "testing" + + "k8s.io/kube-openapi/pkg/validation/spec" +) + +func TestGenerate(t *testing.T) { + tmpFile := "/tmp/openapi-test.json" + defer func() { _ = os.Remove(tmpFile) }() + + // Test with no input dirs (POC mode) + gen := NewGenerator(nil, tmpFile) + gen.Title = "Test API" + gen.Version = "v1" + + if err := gen.Generate(); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(tmpFile); err != nil { + t.Fatalf("Output file not created: %v", err) + } + + // Verify it's valid JSON + data, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read output: %v", err) + } + + var swagger spec.Swagger + if err := json.Unmarshal(data, &swagger); err != nil { + t.Fatalf("Output is not valid JSON: %v", err) + } + + // Verify basic structure + if swagger.Swagger != "2.0" { + t.Errorf("Expected Swagger 2.0, got %s", swagger.Swagger) + } + + if swagger.Info.Title != "Test API" { + t.Errorf("Expected title 'Test API', got %s", swagger.Info.Title) + } + + if swagger.Info.Version != "v1" { + t.Errorf("Expected version 'v1', got %s", swagger.Info.Version) + } + + t.Logf("Generated OpenAPI schema:\n%s", string(data)) +} diff --git a/hack/api-codegen/pkg/openapi/types.go b/hack/api-codegen/pkg/openapi/types.go new file mode 100644 index 00000000..15a30a82 --- /dev/null +++ b/hack/api-codegen/pkg/openapi/types.go @@ -0,0 +1,29 @@ +package openapi + +// Generator generates OpenAPI schemas from Go types +type Generator struct { + // InputDirs are the directories containing Go types to generate schemas for + InputDirs []string + + // OutputFile is where to write the OpenAPI schema + OutputFile string + + // Title is the API title + Title string + + // Version is the API version + Version string + + // knownTypes tracks which type names we've seen (for $ref generation) + knownTypes map[string]bool +} + +// NewGenerator creates a new OpenAPI generator +func NewGenerator(inputDirs []string, outputFile string) *Generator { + return &Generator{ + InputDirs: inputDirs, + OutputFile: outputFile, + Title: "HyperFleet API", + Version: "v1alpha1", + } +} diff --git a/hack/api-codegen/pkg/passthrough/generator.go b/hack/api-codegen/pkg/passthrough/generator.go new file mode 100644 index 00000000..15672b87 --- /dev/null +++ b/hack/api-codegen/pkg/passthrough/generator.go @@ -0,0 +1,158 @@ +package passthrough + +import ( + "bytes" + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "strings" + "text/template" +) + +const passthroughTemplate = `// Code generated by passthrough-gen. DO NOT EDIT. + +package {{ .PackageName }} + +{{ if .Imports }} +import ( +{{- range .Imports }} + {{ . }} +{{- end }} +) +{{ end }} + +{{- range .Types }} + +// {{ .Name }} mirrors {{ .SourceName }} from upstream HyperShift +type {{ .Name }} struct { +{{- range .Fields }} + {{- if .Doc }} + // {{ .Doc }} + {{- end }} + {{- range .Markers }} + // {{ . }} + {{- end }} + {{ .Name }} {{ .Type }} ` + "`json:\"{{ .JSONTag }}\"`" + ` +{{- end }} +} +{{- end }} +` + +type templateData struct { + PackageName string + Imports []string + Types []*TypeDef +} + +// Generate creates Go source files for the passthrough types +func (g *Generator) Generate(outputDir string) error { + // Ensure output directory exists + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + // Generate TypeDef for each source type + var typeDefs []*TypeDef + for _, typeName := range g.SourceTypes { + typeDef, err := g.GenerateTypeDef(typeName) + if err != nil { + return fmt.Errorf("generating type def for %s: %w", typeName, err) + } + typeDefs = append(typeDefs, typeDef) + } + + // Collect unique imports needed + imports := g.collectImports(typeDefs) + + // Prepare template data + data := templateData{ + PackageName: g.OutputPackage, + Imports: imports, + Types: typeDefs, + } + + // Execute template + tmpl, err := template.New("passthrough").Parse(passthroughTemplate) + if err != nil { + return fmt.Errorf("parsing template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("executing template: %w", err) + } + + // Write unformatted first for debugging + outputFile := filepath.Join(outputDir, "zz_generated.passthrough.go") + if err := os.WriteFile(outputFile+".raw", buf.Bytes(), 0644); err != nil { + return fmt.Errorf("writing raw output file: %w", err) + } + + // Format the generated code + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("formatting generated code: %w", err) + } + + // Write to file + if err := os.WriteFile(outputFile, formatted, 0644); err != nil { + return fmt.Errorf("writing output file: %w", err) + } + + return nil +} + +// collectImports extracts unique imports needed for the generated types +func (g *Generator) collectImports(typeDefs []*TypeDef) []string { + importSet := make(map[string]bool) + + // Add source package import if we have a source package + if g.SourcePackage != "" && g.SourcePackageAlias != "" { + importSet[fmt.Sprintf(`%s "%s"`, g.SourcePackageAlias, g.SourcePackage)] = true + } + + for _, typeDef := range typeDefs { + for _, field := range typeDef.Fields { + // Strip pointer/slice prefixes repeatedly to handle combined + // forms like []*configv1.Something, then extract map values. + typeStr := field.Type + for strings.HasPrefix(typeStr, "*") || strings.HasPrefix(typeStr, "[]") { + typeStr = strings.TrimPrefix(typeStr, "*") + typeStr = strings.TrimPrefix(typeStr, "[]") + } + if strings.HasPrefix(typeStr, "map[") { + // Extract value type from map[K]V + if idx := strings.LastIndex(typeStr, "]"); idx != -1 && idx+1 < len(typeStr) { + typeStr = typeStr[idx+1:] + typeStr = strings.TrimPrefix(typeStr, "*") + } + } + + // Extract package from type names like "configv1.URL" + if strings.Contains(typeStr, ".") { + // For now, we'll need to manually map these + // In a real implementation, we'd track imports from the AST + if strings.HasPrefix(typeStr, "configv1.") { + importSet[`configv1 "github.com/openshift/api/config/v1"`] = true + } + if strings.HasPrefix(typeStr, "corev1.") { + importSet[`corev1 "k8s.io/api/core/v1"`] = true + } + if strings.HasPrefix(typeStr, "metav1.") { + importSet[`metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"`] = true + } + } + } + } + + // Convert set to sorted slice + imports := make([]string, 0, len(importSet)) + for imp := range importSet { + imports = append(imports, imp) + } + sort.Strings(imports) + + return imports +} diff --git a/hack/api-codegen/pkg/passthrough/generator_test.go b/hack/api-codegen/pkg/passthrough/generator_test.go new file mode 100644 index 00000000..e4882bee --- /dev/null +++ b/hack/api-codegen/pkg/passthrough/generator_test.go @@ -0,0 +1,67 @@ +package passthrough + +import ( + "os" + "path/filepath" + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/markers" +) + +func TestGenerate(t *testing.T) { + // Create generator from import path (resolves via go.mod) + gen, err := NewGeneratorFromImportPath( + "github.com/openshift/hypershift/api/hypershift/v1beta1", + []string{"HostedClusterSpec"}, + make(markers.FieldRegistry), + ) + if err != nil { + t.Fatalf("Failed to create generator: %v", err) + } + + if err := gen.LoadSourceFiles(gen.SourceDir); err != nil { + t.Fatalf("Failed to load source files: %v", err) + } + + // Create temp output directory + tmpDir := t.TempDir() + + t.Logf("Generating to: %s", tmpDir) + if err := gen.Generate(tmpDir); err != nil { + // Try to read raw output for debugging + rawFile := filepath.Join(tmpDir, "zz_generated.passthrough.go.raw") + if raw, err2 := os.ReadFile(rawFile); err2 == nil { + t.Logf("Raw generated output:\n%s", raw) + } + t.Fatalf("Failed to generate: %v", err) + } + + // Check output file exists + outputFile := filepath.Join(tmpDir, "zz_generated.passthrough.go") + if _, err := os.Stat(outputFile); err != nil { + t.Fatalf("Output file not created: %v", err) + } + + // Read and display generated content + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output: %v", err) + } + + t.Logf("Generated file size: %d bytes", len(content)) + + // Show first 50 lines + lines := 0 + for i, b := range content { + if b == '\n' { + lines++ + if lines >= 50 { + t.Logf("First 50 lines of generated code:\n%s\n... (truncated)", content[:i]) + break + } + } + } + if lines < 50 { + t.Logf("Generated code:\n%s", content) + } +} diff --git a/hack/api-codegen/pkg/passthrough/gomod.go b/hack/api-codegen/pkg/passthrough/gomod.go new file mode 100644 index 00000000..994b4f0e --- /dev/null +++ b/hack/api-codegen/pkg/passthrough/gomod.go @@ -0,0 +1,63 @@ +package passthrough + +import ( + "fmt" + "go/ast" + "os/exec" + "strings" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/markers" +) + +// ResolvePackageDir uses `go list` to find the directory for a Go package import path. +// This works for both local packages and module dependencies in go.mod. +// +// Example: +// +// ResolvePackageDir("github.com/openshift/hypershift/api/hypershift/v1beta1") +// => "/Users/user/go/pkg/mod/github.com/openshift/hypershift/api@v0.0.0-20251113065312-f919037748bf/hypershift/v1beta1" +func ResolvePackageDir(importPath string) (string, error) { + cmd := exec.Command("go", "list", "-f", "{{.Dir}}", importPath) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to resolve package %s: %w\nOutput: %s", importPath, err, string(output)) + } + + dir := strings.TrimSpace(string(output)) + if dir == "" { + return "", fmt.Errorf("go list returned empty directory for %s", importPath) + } + + return dir, nil +} + +// NewGeneratorFromImportPath creates a generator by resolving the source directory +// from a Go import path using `go list`. +// +// Example: +// +// gen, err := NewGeneratorFromImportPath( +// "github.com/openshift/hypershift/api/hypershift/v1beta1", +// []string{"HostedClusterSpec", "NodePoolSpec"}, +// registry, +// ) +func NewGeneratorFromImportPath(importPath string, sourceTypes []string, registry markers.FieldRegistry) (*Generator, error) { + sourceDir, err := ResolvePackageDir(importPath) + if err != nil { + return nil, fmt.Errorf("resolving import path %s: %w", importPath, err) + } + + // Extract package alias from import path (last segment) + parts := strings.Split(importPath, "/") + packageAlias := "hypershift" + parts[len(parts)-1] // e.g., "hypershiftv1beta1" + + return &Generator{ + SourceDir: sourceDir, + SourceTypes: sourceTypes, + OutputPackage: "v1alpha1", + Registry: registry, + SourcePackage: importPath, + SourcePackageAlias: packageAlias, + parsedFiles: make(map[string]*ast.File), + }, nil +} diff --git a/hack/api-codegen/pkg/passthrough/integration_test.go b/hack/api-codegen/pkg/passthrough/integration_test.go new file mode 100644 index 00000000..8a842331 --- /dev/null +++ b/hack/api-codegen/pkg/passthrough/integration_test.go @@ -0,0 +1,105 @@ +package passthrough_test + +import ( + "os" + "path/filepath" + "testing" + + // Import HyperShift API to ensure it's in go.mod as a direct dependency + _ "github.com/openshift/hypershift/api/hypershift/v1beta1" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/markers" + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/passthrough" +) + +// TestGenerateFromHyperShiftModule verifies passthrough generation from go.mod dependency +func TestGenerateFromHyperShiftModule(t *testing.T) { + // Create generator from import path (resolves via go.mod) + registry := make(markers.FieldRegistry) + types := []string{"HostedClusterSpec", "NodePoolSpec"} + + gen, err := passthrough.NewGeneratorFromImportPath( + "github.com/openshift/hypershift/api/hypershift/v1beta1", + types, + registry, + ) + if err != nil { + t.Fatalf("Failed to create generator from import path: %v", err) + } + + // Verify source directory was resolved + if gen.SourceDir == "" { + t.Fatal("SourceDir is empty") + } + + t.Logf("Resolved source directory: %s", gen.SourceDir) + + // Load source files + if err := gen.LoadSourceFiles(gen.SourceDir); err != nil { + t.Fatalf("Failed to load source files: %v", err) + } + + parsedFiles := gen.ParsedFiles() + if len(parsedFiles) == 0 { + t.Fatal("No source files were parsed") + } + + t.Logf("Loaded %d source files", len(parsedFiles)) + + // Generate to temp directory + outputDir := t.TempDir() + if err := gen.Generate(outputDir); err != nil { + t.Fatalf("Failed to generate: %v", err) + } + + // Verify output file exists + outputFile := filepath.Join(outputDir, "zz_generated.passthrough.go") + if _, err := os.Stat(outputFile); os.IsNotExist(err) { + t.Fatalf("Output file not created: %s", outputFile) + } + + // Read and verify output + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Basic sanity checks + contentStr := string(content) + if len(content) == 0 { + t.Fatal("Generated file is empty") + } + + // Check for expected type definitions + if !contains(contentStr, "type HostedClusterSpecPassthrough struct") { + t.Error("Missing HostedClusterSpecPassthrough type definition") + } + + if !contains(contentStr, "type NodePoolSpecPassthrough struct") { + t.Error("Missing NodePoolSpecPassthrough type definition") + } + + // Check for safe default markers + if !contains(contentStr, "+k8s:openapi-gen=false") { + t.Error("Missing visibility marker") + } + + if !contains(contentStr, "+hyperfleet:write-mode=service-set") { + t.Error("Missing write-mode marker") + } + + t.Logf("Successfully generated %d bytes", len(content)) +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && len(s) >= len(substr) && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/hack/api-codegen/pkg/passthrough/loader.go b/hack/api-codegen/pkg/passthrough/loader.go new file mode 100644 index 00000000..9478911a --- /dev/null +++ b/hack/api-codegen/pkg/passthrough/loader.go @@ -0,0 +1,277 @@ +package passthrough + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "strings" + "unicode" +) + +// LoadSourceFiles loads and parses Go source files from a directory +func (g *Generator) LoadSourceFiles(sourceDir string) error { + fset := token.NewFileSet() + + // Parse all Go files in the directory + //nolint:staticcheck // ParseDir is sufficient for our use case of parsing single directories + pkgs, err := parser.ParseDir(fset, sourceDir, func(fi os.FileInfo) bool { + // Skip test files and generated files + name := fi.Name() + return !strings.HasSuffix(name, "_test.go") && + !strings.HasPrefix(name, "zz_generated") + }, parser.ParseComments) + + if err != nil { + return fmt.Errorf("parsing directory %s: %w", sourceDir, err) + } + + if len(pkgs) == 0 { + return fmt.Errorf("no packages found in %s", sourceDir) + } + + // Store parsed files + g.parsedFiles = make(map[string]*ast.File) + for _, pkg := range pkgs { + for filename, file := range pkg.Files { + g.parsedFiles[filename] = file + } + } + + return nil +} + +// GenerateTypeDef creates a passthrough type definition for a source type +func (g *Generator) GenerateTypeDef(typeName string) (*TypeDef, error) { + // Find the type definition across all parsed files + var typeSpec *ast.TypeSpec + for _, file := range g.parsedFiles { + ast.Inspect(file, func(n ast.Node) bool { + if ts, ok := n.(*ast.TypeSpec); ok && ts.Name.Name == typeName { + typeSpec = ts + return false + } + return true + }) + if typeSpec != nil { + break + } + } + + if typeSpec == nil { + return nil, fmt.Errorf("type %s not found in parsed files", typeName) + } + + // Ensure it's a struct type + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return nil, fmt.Errorf("type %s is not a struct", typeName) + } + + // Compute effective prefix per type without mutating g.FieldPrefix, + // so subsequent types derive their own prefix independently. + effectivePrefix := g.FieldPrefix + if effectivePrefix == "" { + effectivePrefix = deriveFieldPrefix(typeName) + } + savedPrefix := g.FieldPrefix + g.FieldPrefix = effectivePrefix + defer func() { g.FieldPrefix = savedPrefix }() + + typeDef := &TypeDef{ + Name: typeName + "Passthrough", + SourceName: typeName, + Doc: fmt.Sprintf("%s mirrors %s from upstream", typeName+"Passthrough", typeName), + Fields: make([]FieldDef, 0), + } + + // Process each field + for _, field := range structType.Fields.List { + // Skip fields without names (embedded types) + if len(field.Names) == 0 { + continue + } + + for _, name := range field.Names { + // Skip unexported fields + if !name.IsExported() { + continue + } + + fieldDef := g.createFieldDef(name.Name, field) + typeDef.Fields = append(typeDef.Fields, fieldDef) + } + } + + return typeDef, nil +} + +// createFieldDef creates a field definition with appropriate markers +func (g *Generator) createFieldDef(fieldName string, field *ast.Field) FieldDef { + fieldDef := FieldDef{ + Name: fieldName, + Type: g.typeToString(field.Type), + } + + // Extract JSON tag + if field.Tag != nil { + tag := strings.Trim(field.Tag.Value, "`") + if jsonTag := parseStructTag(tag, "json"); jsonTag != "" { + fieldDef.JSONTag = jsonTag + } + } + + // Extract documentation (first line only, collapsed to single line) + if field.Doc != nil { + doc := strings.TrimSpace(field.Doc.Text()) + // Take only first line and collapse to single line + lines := strings.Split(doc, "\n") + if len(lines) > 0 { + fieldDef.Doc = strings.TrimSpace(lines[0]) + } + } + + // Get markers for this field (use JSON tag name for registry lookup, + // stripping options like ",omitempty" or ",omitzero") + lookupName := fieldDef.JSONTag + if i := strings.Index(lookupName, ","); i != -1 { + lookupName = lookupName[:i] + } + if lookupName == "" { + lookupName = fieldName + } + fieldDef.Markers = g.getMarkersForField(lookupName) + + return fieldDef +} + +// typeToString converts an AST type expression to a string +func (g *Generator) typeToString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + // Check if this is a type from the source package that needs to be qualified + typeName := t.Name + if g.SourcePackageAlias != "" && g.isSourcePackageType(typeName) { + return g.SourcePackageAlias + "." + typeName + } + return typeName + case *ast.StarExpr: + return "*" + g.typeToString(t.X) + case *ast.ArrayType: + return "[]" + g.typeToString(t.Elt) + case *ast.MapType: + return "map[" + g.typeToString(t.Key) + "]" + g.typeToString(t.Value) + case *ast.SelectorExpr: + return g.typeToString(t.X) + "." + t.Sel.Name + case *ast.InterfaceType, *ast.FuncType, *ast.Ellipsis, *ast.IndexExpr, *ast.IndexListExpr: + var buf bytes.Buffer + if err := printer.Fprint(&buf, token.NewFileSet(), expr); err != nil { + return "interface{}" + } + return buf.String() + default: + return "interface{}" + } +} + +// isSourcePackageType checks if a type name is defined in the source package +// (not a built-in like string, int, bool) +func (g *Generator) isSourcePackageType(typeName string) bool { + // Built-in types don't need qualification + builtins := map[string]bool{ + "bool": true, "byte": true, "complex64": true, "complex128": true, + "error": true, "float32": true, "float64": true, "int": true, + "int8": true, "int16": true, "int32": true, "int64": true, + "rune": true, "string": true, "uint": true, "uint8": true, + "uint16": true, "uint32": true, "uint64": true, "uintptr": true, + } + + if builtins[typeName] { + return false + } + + // Check if the type is defined in the parsed source files + for _, file := range g.parsedFiles { + for _, decl := range file.Decls { + if genDecl, ok := decl.(*ast.GenDecl); ok { + for _, spec := range genDecl.Specs { + if typeSpec, ok := spec.(*ast.TypeSpec); ok { + if typeSpec.Name.Name == typeName { + return true + } + } + } + } + } + } + + return false +} + +// getMarkersForField returns markers for a field, from registry or defaults. +// jsonTagName is the JSON struct tag name (e.g., "autoNode"), which is combined +// with g.FieldPrefix to build the registry lookup key (e.g., "spec.hostedCluster.autoNode"). +func (g *Generator) getMarkersForField(jsonTagName string) []string { + lookupKey := jsonTagName + if g.FieldPrefix != "" { + lookupKey = g.FieldPrefix + "." + jsonTagName + } + + if meta, found := g.Registry[lookupKey]; found { + var markers []string + + if meta.Hidden { + markers = append(markers, "+k8s:openapi-gen=false") + } else { + markers = append(markers, "+k8s:openapi-gen=true") + } + + if meta.WriteMode != "" { + markers = append(markers, fmt.Sprintf("+hyperfleet:write-mode=%s", meta.WriteMode)) + } + + if meta.FeatureGate != "" { + markers = append(markers, fmt.Sprintf("+openshift:enable:FeatureGate=%s", meta.FeatureGate)) + } + + return markers + } + + // Apply safe defaults for new fields not in the registry + return []string{ + "+k8s:openapi-gen=false", + "+hyperfleet:write-mode=service-set", + } +} + +// deriveFieldPrefix derives a registry field prefix from a Go type name. +// e.g., "HostedClusterSpec" → "spec.hostedCluster", "NodePoolSpec" → "spec.nodePool" +func deriveFieldPrefix(typeName string) string { + base := strings.TrimSuffix(typeName, "Spec") + if base == typeName { + return "" + } + runes := []rune(base) + runes[0] = unicode.ToLower(runes[0]) + return "spec." + string(runes) +} + +// parseStructTag extracts a specific tag value from struct tag string +func parseStructTag(tag, key string) string { + // Simple tag parser - handles: `json:"name,omitempty" yaml:"name"` + parts := strings.Fields(tag) + prefix := key + `:"` + + for _, part := range parts { + if strings.HasPrefix(part, prefix) { + value := strings.TrimPrefix(part, prefix) + value = strings.TrimSuffix(value, `"`) + return value + } + } + + return "" +} diff --git a/hack/api-codegen/pkg/passthrough/loader_test.go b/hack/api-codegen/pkg/passthrough/loader_test.go new file mode 100644 index 00000000..fc2bbe2d --- /dev/null +++ b/hack/api-codegen/pkg/passthrough/loader_test.go @@ -0,0 +1,203 @@ +package passthrough + +import ( + "go/ast" + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/markers" +) + +func TestLoadHyperShiftTypes(t *testing.T) { + // Create generator from import path (resolves via go.mod) + gen, err := NewGeneratorFromImportPath( + "github.com/openshift/hypershift/api/hypershift/v1beta1", + []string{"HostedClusterSpec"}, + make(markers.FieldRegistry), + ) + if err != nil { + t.Fatalf("Failed to create generator: %v", err) + } + + if err := gen.LoadSourceFiles(gen.SourceDir); err != nil { + t.Fatalf("Failed to load source files: %v", err) + } + + if len(gen.parsedFiles) == 0 { + t.Fatal("No files were loaded") + } + + t.Logf("Loaded %d files from %s", len(gen.parsedFiles), gen.SourceDir) +} + +func TestGetMarkersForField_WithRegistry(t *testing.T) { + registry := markers.FieldRegistry{ + "spec.hostedCluster.autoNode": markers.FieldMeta{ + FieldPath: "spec.hostedCluster.autoNode", + WriteMode: markers.ServiceSet, + Hidden: false, + }, + "spec.hostedCluster.release": markers.FieldMeta{ + FieldPath: "spec.hostedCluster.release", + WriteMode: markers.ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.configuration": markers.FieldMeta{ + FieldPath: "spec.hostedCluster.configuration", + WriteMode: markers.ServiceSet, + Hidden: false, + }, + "spec.hostedCluster.etcd": markers.FieldMeta{ + FieldPath: "spec.hostedCluster.etcd", + WriteMode: markers.Mutable, + FeatureGate: "HyperFleetEtcd", + Hidden: false, + }, + } + + gen := &Generator{ + Registry: registry, + FieldPrefix: "spec.hostedCluster", + parsedFiles: make(map[string]*ast.File), + } + + tests := []struct { + name string + jsonTagName string + wantOpenAPIGen string + wantWriteMode string + wantGate string + wantLen int + }{ + { + name: "visible field gets openapi-gen=true", + jsonTagName: "autoNode", + wantOpenAPIGen: "+k8s:openapi-gen=true", + wantWriteMode: "+hyperfleet:write-mode=service-set", + wantLen: 2, + }, + { + name: "hidden field gets openapi-gen=false", + jsonTagName: "release", + wantOpenAPIGen: "+k8s:openapi-gen=false", + wantWriteMode: "+hyperfleet:write-mode=service-set", + wantLen: 2, + }, + { + name: "field with feature gate emits gate marker", + jsonTagName: "etcd", + wantOpenAPIGen: "+k8s:openapi-gen=true", + wantWriteMode: "+hyperfleet:write-mode=mutable", + wantGate: "+openshift:enable:FeatureGate=HyperFleetEtcd", + wantLen: 3, + }, + { + name: "field not in registry gets defaults", + jsonTagName: "unknownField", + wantOpenAPIGen: "+k8s:openapi-gen=false", + wantWriteMode: "+hyperfleet:write-mode=service-set", + wantLen: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + markers := gen.getMarkersForField(tt.jsonTagName) + + if len(markers) != tt.wantLen { + t.Errorf("expected %d markers, got %d: %v", tt.wantLen, len(markers), markers) + } + + if markers[0] != tt.wantOpenAPIGen { + t.Errorf("expected openapi marker %q, got %q", tt.wantOpenAPIGen, markers[0]) + } + + if markers[1] != tt.wantWriteMode { + t.Errorf("expected write-mode marker %q, got %q", tt.wantWriteMode, markers[1]) + } + + if tt.wantGate != "" && (len(markers) < 3 || markers[2] != tt.wantGate) { + gate := "" + if len(markers) >= 3 { + gate = markers[2] + } + t.Errorf("expected gate marker %q, got %q", tt.wantGate, gate) + } + }) + } +} + +func TestGetMarkersForField_NoPrefix(t *testing.T) { + registry := markers.FieldRegistry{ + "autoNode": markers.FieldMeta{ + FieldPath: "autoNode", + WriteMode: markers.ServiceSet, + Hidden: false, + }, + } + + gen := &Generator{ + Registry: registry, + parsedFiles: make(map[string]*ast.File), + } + + m := gen.getMarkersForField("autoNode") + if m[0] != "+k8s:openapi-gen=true" { + t.Errorf("expected openapi-gen=true with no prefix, got %q", m[0]) + } +} + +func TestDeriveFieldPrefix(t *testing.T) { + tests := []struct { + typeName string + want string + }{ + {"HostedClusterSpec", "spec.hostedCluster"}, + {"NodePoolSpec", "spec.nodePool"}, + {"SomeOtherType", ""}, + } + for _, tt := range tests { + t.Run(tt.typeName, func(t *testing.T) { + got := deriveFieldPrefix(tt.typeName) + if got != tt.want { + t.Errorf("deriveFieldPrefix(%q) = %q, want %q", tt.typeName, got, tt.want) + } + }) + } +} + +func TestGenerateTypeDef(t *testing.T) { + // Create generator from import path (resolves via go.mod) + gen, err := NewGeneratorFromImportPath( + "github.com/openshift/hypershift/api/hypershift/v1beta1", + []string{"HostedClusterSpec"}, + make(markers.FieldRegistry), + ) + if err != nil { + t.Fatalf("Failed to create generator: %v", err) + } + + if err := gen.LoadSourceFiles(gen.SourceDir); err != nil { + t.Fatalf("Failed to load source files: %v", err) + } + + typeDef, err := gen.GenerateTypeDef("HostedClusterSpec") + if err != nil { + t.Fatalf("Failed to generate type def: %v", err) + } + + if typeDef.Name != "HostedClusterSpecPassthrough" { + t.Errorf("Expected name HostedClusterSpecPassthrough, got %s", typeDef.Name) + } + + if len(typeDef.Fields) == 0 { + t.Error("Expected some fields, got none") + } + + t.Logf("Generated %d fields for %s", len(typeDef.Fields), typeDef.Name) + for i, field := range typeDef.Fields { + if i < 5 { // Show first 5 fields + t.Logf(" Field %d: %s %s `json:\"%s\"`", i, field.Name, field.Type, field.JSONTag) + t.Logf(" Markers: %v", field.Markers) + } + } +} diff --git a/hack/api-codegen/pkg/passthrough/types.go b/hack/api-codegen/pkg/passthrough/types.go new file mode 100644 index 00000000..eb379506 --- /dev/null +++ b/hack/api-codegen/pkg/passthrough/types.go @@ -0,0 +1,86 @@ +package passthrough + +import ( + "go/ast" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/markers" +) + +// Generator generates passthrough types from upstream types +type Generator struct { + // SourceDir is the directory containing source Go files + SourceDir string + + // SourceTypes are the type names to generate passthroughs for (e.g., ["HostedClusterSpec", "NodePoolSpec"]) + SourceTypes []string + + // OutputPackage is the package name for generated code + OutputPackage string + + // Registry contains existing field markers to preserve + Registry markers.FieldRegistry + + // SourcePackage is the import path of the source package (e.g., "github.com/openshift/hypershift/api/hypershift/v1beta1") + SourcePackage string + + // SourcePackageAlias is the alias to use for the source package import (e.g., "hypershiftv1") + SourcePackageAlias string + + // FieldPrefix is the dotted path prefix for registry lookups (e.g., "spec.hostedCluster") + FieldPrefix string + + // parsedFiles holds parsed AST of source files + parsedFiles map[string]*ast.File +} + +// ParsedFiles returns the parsed files (for CLI tool) +func (g *Generator) ParsedFiles() map[string]*ast.File { + return g.parsedFiles +} + +// TypeDef represents a generated passthrough type definition +type TypeDef struct { + // Name is the generated type name (e.g., "HostedClusterPassthrough") + Name string + + // SourceName is the original type name (e.g., "HostedCluster") + SourceName string + + // Fields are the struct fields + Fields []FieldDef + + // Doc is the type documentation + Doc string +} + +// FieldDef represents a single field in a passthrough type +type FieldDef struct { + // Name is the Go field name + Name string + + // Type is the Go type (as a string) + Type string + + // JSONTag is the json struct tag + JSONTag string + + // Doc is the field documentation + Doc string + + // Markers are the Go markers to include + Markers []string + + // IsNested indicates if this is a nested struct type that needs its own passthrough + IsNested bool +} + +// NewGenerator creates a new passthrough generator +func NewGenerator(sourceDir string, sourceTypes []string, registry markers.FieldRegistry) *Generator { + return &Generator{ + SourceDir: sourceDir, + SourceTypes: sourceTypes, + OutputPackage: "v1alpha1", + Registry: registry, + parsedFiles: make(map[string]*ast.File), + } +} diff --git a/hack/api-codegen/pkg/registry/field_metadata.go b/hack/api-codegen/pkg/registry/field_metadata.go new file mode 100644 index 00000000..7303b107 --- /dev/null +++ b/hack/api-codegen/pkg/registry/field_metadata.go @@ -0,0 +1,330 @@ +// Code generated by marker-scanner. DO NOT EDIT. + +package registry + +// WriteMode defines how a field can be mutated by customers +type WriteMode string + +const ( + // Mutable fields can be set on create and changed on update + Mutable WriteMode = "mutable" + + // Immutable fields can be set on create but cannot be changed on update + Immutable WriteMode = "immutable" + + // ServiceSet fields are set by the platform and cannot be set by customers + ServiceSet WriteMode = "service-set" +) + +// FeatureGateWriteMode represents a write-mode override for a specific feature gate +type FeatureGateWriteMode struct { + // FeatureGate is the gate that enables this write-mode (empty string = default/no gates enabled) + FeatureGate string + + // WriteMode is the effective write-mode when this gate condition matches + WriteMode WriteMode +} + +// FieldMeta contains metadata for a single field +type FieldMeta struct { + // FieldPath is the JSON path to the field (e.g., "spec.name") + FieldPath string + + // WriteMode controls customer mutability + WriteMode WriteMode + + // FeatureGate is the gate required to use this field (empty if no gate required) + FeatureGate string + + // Hidden indicates if the field is excluded from OpenAPI + Hidden bool + + // FeatureGateAwareWriteModes allows write-mode to vary based on enabled feature gates + FeatureGateAwareWriteModes []FeatureGateWriteMode +} + +// FieldRegistry maps field paths to their metadata +var FieldRegistry = map[string]FieldMeta{ + "spec.accountId": { + FieldPath: "spec.accountId", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.autoRepair": { + FieldPath: "spec.autoRepair", + WriteMode: Mutable, + }, + "spec.creatorARN": { + FieldPath: "spec.creatorARN", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.deleteProtection": { + FieldPath: "spec.deleteProtection", + WriteMode: Mutable, + }, + "spec.displayName": { + FieldPath: "spec.displayName", + WriteMode: Mutable, + }, + "spec.expirationTimestamp": { + FieldPath: "spec.expirationTimestamp", + WriteMode: Mutable, + }, + "spec.hostedCluster.additionalTrustBundle": { + FieldPath: "spec.hostedCluster.additionalTrustBundle", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.auditWebhook": { + FieldPath: "spec.hostedCluster.auditWebhook", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.autoNode": { + FieldPath: "spec.hostedCluster.autoNode", + WriteMode: ServiceSet, + }, + "spec.hostedCluster.autoscaling": { + FieldPath: "spec.hostedCluster.autoscaling", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.capabilities": { + FieldPath: "spec.hostedCluster.capabilities", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.channel": { + FieldPath: "spec.hostedCluster.channel", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.clusterID": { + FieldPath: "spec.hostedCluster.clusterID", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.configuration": { + FieldPath: "spec.hostedCluster.configuration", + WriteMode: ServiceSet, + }, + "spec.hostedCluster.controlPlaneRelease": { + FieldPath: "spec.hostedCluster.controlPlaneRelease", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.controllerAvailabilityPolicy": { + FieldPath: "spec.hostedCluster.controllerAvailabilityPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.dns": { + FieldPath: "spec.hostedCluster.dns", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.etcd": { + FieldPath: "spec.hostedCluster.etcd", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.fips": { + FieldPath: "spec.hostedCluster.fips", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.imageContentSources": { + FieldPath: "spec.hostedCluster.imageContentSources", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.infraID": { + FieldPath: "spec.hostedCluster.infraID", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.infrastructureAvailabilityPolicy": { + FieldPath: "spec.hostedCluster.infrastructureAvailabilityPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.issuerURL": { + FieldPath: "spec.hostedCluster.issuerURL", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.kubeAPIServerDNSName": { + FieldPath: "spec.hostedCluster.kubeAPIServerDNSName", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.labels": { + FieldPath: "spec.hostedCluster.labels", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.networking": { + FieldPath: "spec.hostedCluster.networking", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.nodeSelector": { + FieldPath: "spec.hostedCluster.nodeSelector", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.olmCatalogPlacement": { + FieldPath: "spec.hostedCluster.olmCatalogPlacement", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.operatorConfiguration": { + FieldPath: "spec.hostedCluster.operatorConfiguration", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.pausedUntil": { + FieldPath: "spec.hostedCluster.pausedUntil", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.platform": { + FieldPath: "spec.hostedCluster.platform", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.pullSecret": { + FieldPath: "spec.hostedCluster.pullSecret", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.release": { + FieldPath: "spec.hostedCluster.release", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.secretEncryption": { + FieldPath: "spec.hostedCluster.secretEncryption", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.serviceAccountSigningKey": { + FieldPath: "spec.hostedCluster.serviceAccountSigningKey", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.services": { + FieldPath: "spec.hostedCluster.services", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.sshKey": { + FieldPath: "spec.hostedCluster.sshKey", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.tolerations": { + FieldPath: "spec.hostedCluster.tolerations", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.updateService": { + FieldPath: "spec.hostedCluster.updateService", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.internalId": { + FieldPath: "spec.internalId", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.internalPoolId": { + FieldPath: "spec.internalPoolId", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.labels": { + FieldPath: "spec.labels", + WriteMode: Mutable, + }, + "spec.nodePool.arch": { + FieldPath: "spec.nodePool.arch", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.autoScaling": { + FieldPath: "spec.nodePool.autoScaling", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.clusterName": { + FieldPath: "spec.nodePool.clusterName", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.config": { + FieldPath: "spec.nodePool.config", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.management": { + FieldPath: "spec.nodePool.management", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.nodeDrainTimeout": { + FieldPath: "spec.nodePool.nodeDrainTimeout", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.nodeLabels": { + FieldPath: "spec.nodePool.nodeLabels", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.nodeVolumeDetachTimeout": { + FieldPath: "spec.nodePool.nodeVolumeDetachTimeout", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.pausedUntil": { + FieldPath: "spec.nodePool.pausedUntil", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.platform": { + FieldPath: "spec.nodePool.platform", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.release": { + FieldPath: "spec.nodePool.release", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.replicas": { + FieldPath: "spec.nodePool.replicas", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.taints": { + FieldPath: "spec.nodePool.taints", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.tuningConfig": { + FieldPath: "spec.nodePool.tuningConfig", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.properties": { + FieldPath: "spec.properties", + WriteMode: Mutable, + }, + "spec.tags": { + FieldPath: "spec.tags", + WriteMode: Mutable, + FeatureGate: "HyperFleetAutoScaling", + }, +} diff --git a/hack/api-codegen/pkg/registry/field_metadata.json b/hack/api-codegen/pkg/registry/field_metadata.json new file mode 100644 index 00000000..167ecff8 --- /dev/null +++ b/hack/api-codegen/pkg/registry/field_metadata.json @@ -0,0 +1,577 @@ +[ + { + "fieldPath": "allowedUnsafeSysctls", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "apiServer", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "authentication", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "containerLogMaxFiles", + "writeMode": "mutable" + }, + { + "fieldPath": "containerLogMaxSize", + "writeMode": "mutable" + }, + { + "fieldPath": "cpuManagerPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "cpuManagerPolicyOptions", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "cpuManagerReconcilePeriod", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "evictionHard", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "evictionSoft", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "evictionSoftGracePeriod", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "featureGate", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "image", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "imageGCHighThresholdPercent", + "writeMode": "mutable" + }, + { + "fieldPath": "imageGCLowThresholdPercent", + "writeMode": "mutable" + }, + { + "fieldPath": "imageMinimumGCAge", + "writeMode": "mutable" + }, + { + "fieldPath": "ingress", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubeReserved", + "writeMode": "immutable" + }, + { + "fieldPath": "kubelet", + "writeMode": "service-set" + }, + { + "fieldPath": "kubelet.allowedUnsafeSysctls", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.containerLogMaxFiles", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.containerLogMaxSize", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.cpuManagerPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.cpuManagerPolicyOptions", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.cpuManagerReconcilePeriod", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.evictionHard", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.evictionSoft", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.evictionSoftGracePeriod", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.imageGCHighThresholdPercent", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.imageGCLowThresholdPercent", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.imageMinimumGCAge", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.kubeReserved", + "writeMode": "immutable" + }, + { + "fieldPath": "kubelet.maxPods", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.memoryThrottlingFactor", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.podPidsLimit", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.registryBurst", + "writeMode": "mutable", + "featureGate": "HyperFleetKubeletAdvanced" + }, + { + "fieldPath": "kubelet.registryPullQPS", + "writeMode": "mutable", + "featureGate": "HyperFleetKubeletAdvanced" + }, + { + "fieldPath": "kubelet.serializeImagePulls", + "writeMode": "mutable", + "featureGate": "HyperFleetKubeletAdvanced" + }, + { + "fieldPath": "kubelet.streamingConnectionIdleTimeout", + "writeMode": "mutable" + }, + { + "fieldPath": "kubelet.systemReserved", + "writeMode": "immutable" + }, + { + "fieldPath": "kubelet.topologyManagerPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "kubelet.topologyManagerScope", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "machineConfig", + "writeMode": "service-set" + }, + { + "fieldPath": "machineConfig.allowedKernelArguments", + "writeMode": "immutable", + "featureGate": "HyperFleetMachineConfig" + }, + { + "fieldPath": "machineConfig.extensions", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "machineConfig.files", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "machineConfig.fips", + "writeMode": "immutable" + }, + { + "fieldPath": "machineConfig.kernelArguments", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "machineConfig.kernelType", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "machineConfig.systemdUnits", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "maxPods", + "writeMode": "mutable" + }, + { + "fieldPath": "memoryThrottlingFactor", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "network", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "oauth", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "podPidsLimit", + "writeMode": "mutable" + }, + { + "fieldPath": "proxy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "registryBurst", + "writeMode": "mutable", + "featureGate": "HyperFleetKubeletAdvanced" + }, + { + "fieldPath": "registryPullQPS", + "writeMode": "mutable", + "featureGate": "HyperFleetKubeletAdvanced" + }, + { + "fieldPath": "scheduler", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "serializeImagePulls", + "writeMode": "mutable", + "featureGate": "HyperFleetKubeletAdvanced" + }, + { + "fieldPath": "spec.accountId", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.autoRepair", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.creatorARN", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.deleteProtection", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.displayName", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.expirationTimestamp", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.hostedCluster.additionalTrustBundle", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.auditWebhook", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.autoNode", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.autoscaling", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.capabilities", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.channel", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.clusterID", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.configuration", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.controlPlaneRelease", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.controllerAvailabilityPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.dns", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.etcd", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.fips", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.imageContentSources", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.infraID", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.infrastructureAvailabilityPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.issuerURL", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.kubeAPIServerDNSName", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.labels", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.networking", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.nodeSelector", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.olmCatalogPlacement", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.operatorConfiguration", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.pausedUntil", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.platform", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.pullSecret", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.release", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.secretEncryption", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.serviceAccountSigningKey", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.services", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.sshKey", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.tolerations", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.updateService", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.internalId", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.internalPoolId", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.labels", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.nodePool.arch", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.autoScaling", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.clusterName", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.config", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.management", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.nodeDrainTimeout", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.nodeLabels", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.nodeVolumeDetachTimeout", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.pausedUntil", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.platform", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.release", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.replicas", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.taints", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.tuningConfig", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.properties", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.tags", + "writeMode": "mutable", + "featureGate": "HyperFleetAutoScaling" + }, + { + "fieldPath": "streamingConnectionIdleTimeout", + "writeMode": "mutable" + }, + { + "fieldPath": "systemReserved", + "writeMode": "immutable" + }, + { + "fieldPath": "topologyManagerPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "topologyManagerScope", + "writeMode": "service-set", + "hidden": true + } +] \ No newline at end of file diff --git a/hack/api-codegen/pkg/validation/example_test.go b/hack/api-codegen/pkg/validation/example_test.go new file mode 100644 index 00000000..4372667a --- /dev/null +++ b/hack/api-codegen/pkg/validation/example_test.go @@ -0,0 +1,140 @@ +package validation_test + +import ( + "fmt" + "log" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/validation" +) + +// Example of validating a cluster create request +func ExampleValidator_Validate_create() { + v := validation.NewValidator() + + // Customer tries to create a cluster + req := &validation.Request{ + Operation: validation.OperationCreate, + Fields: map[string]any{ + "spec.displayName": "my-cluster", + "spec.deleteProtection": true, + "spec.labels": map[string]string{"env": "prod"}, + }, + FeatureSet: featuregate.Default, + } + + if err := v.Validate(req); err != nil { + log.Fatalf("Validation failed: %v", err) + } + + fmt.Println("Create request is valid") + // Output: Create request is valid +} + +// Example of blocking service-set fields +func ExampleValidator_Validate_serviceSet() { + v := validation.NewValidator() + + // Customer tries to set a service-set field + req := &validation.Request{ + Operation: validation.OperationCreate, + Fields: map[string]any{ + "spec.accountId": "my-account", // This is service-set! + }, + FeatureSet: featuregate.Default, + } + + err := v.Validate(req) + fmt.Printf("Error: %v\n", err) + // Output: + // Error: validation failed: + // field spec.accountId: field is platform-managed (service-set) and cannot be set by customers +} + +// Example of blocking immutable field changes +func ExampleValidator_Validate_immutable() { + // Note: The real registry doesn't have immutable fields yet, + // but the validator supports them via write-mode markers + fmt.Println("Immutable fields can be set on create but not changed on update") + // Output: Immutable fields can be set on create but not changed on update +} + +// Example of feature gate enforcement +func ExampleValidator_Validate_featureGate() { + v := validation.NewValidator() + + // Default customer tries to use a TechPreview feature + req := &validation.Request{ + Operation: validation.OperationCreate, + Fields: map[string]any{ + "spec.tags": map[string]string{"team": "platform"}, + }, + FeatureSet: featuregate.Default, // Tags require TechPreview + } + + err := v.Validate(req) + fmt.Printf("Error: %v\n", err) + // Output: + // Error: validation failed: + // field spec.tags: requires feature gate HyperFleetAutoScaling which is not enabled in Default feature set +} + +// Example of feature gate allowing access +func ExampleValidator_Validate_featureGateAllowed() { + v := validation.NewValidator() + + // TechPreview customer can use TechPreview features + req := &validation.Request{ + Operation: validation.OperationCreate, + Fields: map[string]any{ + "spec.tags": map[string]string{"team": "platform"}, + }, + FeatureSet: featuregate.TechPreviewNoUpgrade, + } + + if err := v.Validate(req); err != nil { + log.Fatalf("Validation failed: %v", err) + } + + fmt.Println("TechPreview customer can use tags") + // Output: TechPreview customer can use tags +} + +// Example of checking field access +func ExampleValidator_ValidateFieldAccess() { + v := validation.NewValidator() + + // Check if a customer can access a gated field + err := v.ValidateFieldAccess("spec.tags", featuregate.Default) + if err != nil { + fmt.Println("Default customer cannot access tags field") + } + + // TechPreview customer can access it + err = v.ValidateFieldAccess("spec.tags", featuregate.TechPreviewNoUpgrade) + if err == nil { + fmt.Println("TechPreview customer can access tags field") + } + + // Output: + // Default customer cannot access tags field + // TechPreview customer can access tags field +} + +// Example of getting field metadata +func ExampleValidator_GetFieldMetadata() { + v := validation.NewValidator() + + meta, exists := v.GetFieldMetadata("spec.displayName") + if exists { + fmt.Printf("Field: %s\n", meta.FieldPath) + fmt.Printf("WriteMode: %s\n", meta.WriteMode) + fmt.Printf("Hidden: %v\n", meta.Hidden) + fmt.Printf("FeatureGate: %s\n", meta.FeatureGate) + } + // Output: + // Field: spec.displayName + // WriteMode: mutable + // Hidden: false + // FeatureGate: +} diff --git a/hack/api-codegen/pkg/validation/gated_writemode_test.go b/hack/api-codegen/pkg/validation/gated_writemode_test.go new file mode 100644 index 00000000..1a2f227d --- /dev/null +++ b/hack/api-codegen/pkg/validation/gated_writemode_test.go @@ -0,0 +1,227 @@ +package validation + +import ( + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/registry" +) + +func TestValidator_FeatureGateAwareWriteMode(t *testing.T) { + tests := []struct { + name string + fieldPath string + baseMode registry.WriteMode + gatedModes []registry.FeatureGateWriteMode + enabledGates []string + operation Operation + expectError bool + errorReason string + }{ + { + name: "Default customers get immutable - blocked on update", + fieldPath: "spec.releaseChannel", + baseMode: registry.Immutable, + gatedModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.Immutable}, + {FeatureGate: "PremiumFeature", WriteMode: registry.Mutable}, + }, + enabledGates: []string{}, + operation: OperationUpdate, + expectError: true, + errorReason: "immutable", + }, + { + name: "Default customers get immutable - allowed on create", + fieldPath: "spec.releaseChannel", + baseMode: registry.Immutable, + gatedModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.Immutable}, + {FeatureGate: "PremiumFeature", WriteMode: registry.Mutable}, + }, + enabledGates: []string{}, + operation: OperationCreate, + expectError: false, + }, + { + name: "Premium customers get mutable - allowed on update", + fieldPath: "spec.releaseChannel", + baseMode: registry.Immutable, + gatedModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.Immutable}, + {FeatureGate: "PremiumFeature", WriteMode: registry.Mutable}, + }, + enabledGates: []string{"PremiumFeature"}, + operation: OperationUpdate, + expectError: false, + }, + { + name: "Premium customers get mutable - allowed on create", + fieldPath: "spec.releaseChannel", + baseMode: registry.Immutable, + gatedModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.Immutable}, + {FeatureGate: "PremiumFeature", WriteMode: registry.Mutable}, + }, + enabledGates: []string{"PremiumFeature"}, + operation: OperationCreate, + expectError: false, + }, + { + name: "TechPreview customers get mutable for gated field - allowed on create", + fieldPath: "spec.etcd", + baseMode: registry.ServiceSet, + gatedModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.ServiceSet}, + {FeatureGate: "HyperFleetEtcdConfig", WriteMode: registry.Mutable}, + }, + enabledGates: []string{"HyperFleetEtcdConfig"}, + operation: OperationCreate, + expectError: false, + }, + { + name: "Default customers get service-set for gated field - blocked", + fieldPath: "spec.etcd", + baseMode: registry.ServiceSet, + gatedModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.ServiceSet}, + {FeatureGate: "HyperFleetEtcdConfig", WriteMode: registry.Mutable}, + }, + enabledGates: []string{}, + operation: OperationCreate, + expectError: true, + errorReason: "service-set", + }, + { + name: "No gated modes - uses base mode (immutable on update blocked)", + fieldPath: "spec.name", + baseMode: registry.Immutable, + gatedModes: []registry.FeatureGateWriteMode{}, + enabledGates: []string{}, + operation: OperationUpdate, + expectError: true, + errorReason: "immutable", + }, + { + name: "No gated modes - uses base mode (mutable on update allowed)", + fieldPath: "spec.tags", + baseMode: registry.Mutable, + gatedModes: []registry.FeatureGateWriteMode{}, + enabledGates: []string{}, + operation: OperationUpdate, + expectError: false, + }, + { + name: "Multiple gates - first match wins", + fieldPath: "spec.advanced", + baseMode: registry.ServiceSet, + gatedModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.ServiceSet}, + {FeatureGate: "FeatureA", WriteMode: registry.Immutable}, + {FeatureGate: "FeatureB", WriteMode: registry.Mutable}, + }, + enabledGates: []string{"FeatureA", "FeatureB"}, + operation: OperationUpdate, + expectError: true, // FeatureA (immutable) takes precedence + errorReason: "immutable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a validator with a custom registry for this test + validator := &Validator{ + registry: map[string]registry.FieldMeta{ + tt.fieldPath: { + FieldPath: tt.fieldPath, + WriteMode: tt.baseMode, + FeatureGateAwareWriteModes: tt.gatedModes, + }, + }, + } + + // Create request + req := &Request{ + Operation: tt.operation, + Fields: map[string]interface{}{tt.fieldPath: "test-value"}, + FeatureSet: featuregate.Default, + EnabledGates: tt.enabledGates, + ExistingFields: map[string]interface{}{ + tt.fieldPath: "old-value", // Simulate existing field for update tests + }, + } + + // For create operations, don't set ExistingFields + if tt.operation == OperationCreate { + req.ExistingFields = nil + } + + // Validate + err := validator.Validate(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing %q, got nil", tt.errorReason) + } else if tt.errorReason != "" { + // Check error contains expected reason + errStr := err.Error() + if errStr == "" || len(errStr) == 0 { + t.Errorf("Expected error containing %q, got empty error", tt.errorReason) + } + // Just verify error exists - don't check specific message + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + } + }) + } +} + +func TestRequest_IsFeatureGateEnabled(t *testing.T) { + tests := []struct { + name string + enabledGates []string + queryGate string + want bool + }{ + { + name: "Gate is enabled", + enabledGates: []string{"FeatureA", "FeatureB"}, + queryGate: "FeatureA", + want: true, + }, + { + name: "Gate is not enabled", + enabledGates: []string{"FeatureA", "FeatureB"}, + queryGate: "FeatureC", + want: false, + }, + { + name: "Empty gates list", + enabledGates: []string{}, + queryGate: "FeatureA", + want: false, + }, + { + name: "Nil gates list", + enabledGates: nil, + queryGate: "FeatureA", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &Request{ + EnabledGates: tt.enabledGates, + } + + got := req.IsFeatureGateEnabled(tt.queryGate) + if got != tt.want { + t.Errorf("IsFeatureGateEnabled(%q) = %v, want %v", tt.queryGate, got, tt.want) + } + }) + } +} diff --git a/hack/api-codegen/pkg/validation/validator.go b/hack/api-codegen/pkg/validation/validator.go new file mode 100644 index 00000000..da547654 --- /dev/null +++ b/hack/api-codegen/pkg/validation/validator.go @@ -0,0 +1,214 @@ +package validation + +import ( + "fmt" + "reflect" + "strings" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/registry" +) + +// Operation represents the type of API operation +type Operation string + +const ( + // OperationCreate is for creating new resources + OperationCreate Operation = "create" + // OperationUpdate is for updating existing resources + OperationUpdate Operation = "update" +) + +// Request represents an API request to validate +type Request struct { + // Operation is the type of operation (create or update) + Operation Operation + + // Fields maps field paths to their values (for validation we only need the paths) + Fields map[string]interface{} + + // FeatureSet is the customer's feature set (Default, TechPreview, DevPreview) + FeatureSet featuregate.FeatureSet + + // ExistingFields contains field paths from the existing resource (for update operations) + // Used to detect which fields are being changed + ExistingFields map[string]interface{} + + // EnabledGates is the list of feature gates enabled for this customer + // Used to determine effective write-mode when FeatureGateAwareWriteModes is set + EnabledGates []string +} + +// IsFeatureGateEnabled returns true if the given feature gate is enabled for this request +func (r *Request) IsFeatureGateEnabled(gateName string) bool { + for _, gate := range r.EnabledGates { + if gate == gateName { + return true + } + } + return false +} + +// ValidationError represents a validation failure +type ValidationError struct { + FieldPath string + Reason string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("field %s: %s", e.FieldPath, e.Reason) +} + +// ValidationErrors is a collection of validation errors +type ValidationErrors []*ValidationError + +func (e ValidationErrors) Error() string { + if len(e) == 0 { + return "no validation errors" + } + + var sb strings.Builder + sb.WriteString("validation failed:\n") + for _, err := range e { + sb.WriteString(" ") + sb.WriteString(err.Error()) + sb.WriteString("\n") + } + return sb.String() +} + +// Validator validates API requests against field metadata +type Validator struct { + registry map[string]registry.FieldMeta +} + +// NewValidator creates a validator using the generated field registry +func NewValidator() *Validator { + return &Validator{ + registry: registry.FieldRegistry, + } +} + +// Validate checks a request against field metadata rules +func (v *Validator) Validate(req *Request) error { + var errors ValidationErrors + + for fieldPath := range req.Fields { + meta, exists := v.registry[fieldPath] + if !exists { + // Field not in registry - might be a field without markers (allowed) + continue + } + + // Check feature gate access + if meta.FeatureGate != "" { + if !featuregate.IsGateEnabled(meta.FeatureGate, req.FeatureSet) { + errors = append(errors, &ValidationError{ + FieldPath: fieldPath, + Reason: fmt.Sprintf("requires feature gate %s which is not enabled in %s feature set", meta.FeatureGate, req.FeatureSet), + }) + continue + } + } + + // Check write mode + if err := v.validateWriteMode(fieldPath, meta, req); err != nil { + errors = append(errors, err) + } + } + + if len(errors) > 0 { + return errors + } + + return nil +} + +// validateWriteMode checks if a field can be set based on its write mode +func (v *Validator) validateWriteMode(fieldPath string, meta registry.FieldMeta, req *Request) *ValidationError { + // Determine effective write-mode based on feature-gate-aware overrides + effectiveMode := meta.WriteMode // Default fallback + + if len(meta.FeatureGateAwareWriteModes) > 0 { + // Check for specific gate match first (takes precedence) + matched := false + for _, override := range meta.FeatureGateAwareWriteModes { + if override.FeatureGate != "" && req.IsFeatureGateEnabled(override.FeatureGate) { + effectiveMode = override.WriteMode + matched = true + break // First specific match wins + } + } + + // If no specific match, check for default override (empty gate) + if !matched { + for _, override := range meta.FeatureGateAwareWriteModes { + if override.FeatureGate == "" { + effectiveMode = override.WriteMode + break + } + } + } + } + + // Enforce the effective mode + switch effectiveMode { + case registry.ServiceSet: + // Service-set fields cannot be set by customers at all + return &ValidationError{ + FieldPath: fieldPath, + Reason: "field is platform-managed (service-set) and cannot be set by customers", + } + + case registry.Immutable: + // Immutable fields can be set on create but not changed on update + if req.Operation == OperationUpdate { + if req.ExistingFields != nil { + oldVal, existsInOld := req.ExistingFields[fieldPath] + if existsInOld { + newVal := req.Fields[fieldPath] + if !reflect.DeepEqual(oldVal, newVal) { + return &ValidationError{ + FieldPath: fieldPath, + Reason: "field is immutable and cannot be changed after creation", + } + } + } + } + } + return nil + + case registry.Mutable: + // Mutable fields can always be set + return nil + + default: + // Unknown write mode - be permissive + return nil + } +} + +// ValidateFieldAccess checks if a customer can access a specific field +func (v *Validator) ValidateFieldAccess(fieldPath string, featureSet featuregate.FeatureSet) error { + meta, exists := v.registry[fieldPath] + if !exists { + // Field not in registry - allowed + return nil + } + + // Check feature gate + if meta.FeatureGate != "" { + if !featuregate.IsGateEnabled(meta.FeatureGate, featureSet) { + return fmt.Errorf("field %s requires feature gate %s which is not enabled in %s feature set", + fieldPath, meta.FeatureGate, featureSet) + } + } + + return nil +} + +// GetFieldMetadata returns metadata for a field path +func (v *Validator) GetFieldMetadata(fieldPath string) (registry.FieldMeta, bool) { + meta, exists := v.registry[fieldPath] + return meta, exists +} diff --git a/hack/api-codegen/pkg/validation/validator_test.go b/hack/api-codegen/pkg/validation/validator_test.go new file mode 100644 index 00000000..6ba34cf5 --- /dev/null +++ b/hack/api-codegen/pkg/validation/validator_test.go @@ -0,0 +1,396 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/hack/api-codegen/pkg/registry" +) + +func TestValidator_Validate_WriteMode(t *testing.T) { + tests := []struct { + name string + fieldPath string + writeMode registry.WriteMode + operation Operation + existsInOld bool + wantErr bool + errContains string + }{ + { + name: "mutable field on create - allowed", + fieldPath: "spec.displayName", + writeMode: registry.Mutable, + operation: OperationCreate, + wantErr: false, + }, + { + name: "mutable field on update - allowed", + fieldPath: "spec.displayName", + writeMode: registry.Mutable, + operation: OperationUpdate, + wantErr: false, + }, + { + name: "immutable field on create - allowed", + fieldPath: "spec.name", + writeMode: registry.Immutable, + operation: OperationCreate, + wantErr: false, + }, + { + name: "immutable field on update (value changed) - blocked", + fieldPath: "spec.name", + writeMode: registry.Immutable, + operation: OperationUpdate, + existsInOld: true, + wantErr: true, + errContains: "immutable and cannot be changed", + }, + { + name: "immutable field on update (field new) - allowed", + fieldPath: "spec.name", + writeMode: registry.Immutable, + operation: OperationUpdate, + existsInOld: false, + wantErr: false, + }, + { + name: "service-set field on create - blocked", + fieldPath: "spec.accountId", + writeMode: registry.ServiceSet, + operation: OperationCreate, + wantErr: true, + errContains: "platform-managed", + }, + { + name: "service-set field on update - blocked", + fieldPath: "spec.accountId", + writeMode: registry.ServiceSet, + operation: OperationUpdate, + wantErr: true, + errContains: "platform-managed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test validator with a single field + v := &Validator{ + registry: map[string]registry.FieldMeta{ + tt.fieldPath: { + FieldPath: tt.fieldPath, + WriteMode: tt.writeMode, + }, + }, + } + + req := &Request{ + Operation: tt.operation, + Fields: map[string]interface{}{tt.fieldPath: "test-value"}, + FeatureSet: featuregate.Default, + } + + if tt.existsInOld { + req.ExistingFields = map[string]interface{}{tt.fieldPath: "old-value"} + } + + err := v.Validate(req) + + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Validate() error = %v, want error containing %q", err, tt.errContains) + } + }) + } +} + +func TestValidator_Validate_ImmutableUnchanged(t *testing.T) { + v := &Validator{ + registry: map[string]registry.FieldMeta{ + "spec.name": { + FieldPath: "spec.name", + WriteMode: registry.Immutable, + }, + }, + } + + req := &Request{ + Operation: OperationUpdate, + Fields: map[string]interface{}{"spec.name": "same-value"}, + ExistingFields: map[string]interface{}{"spec.name": "same-value"}, + FeatureSet: featuregate.Default, + } + + if err := v.Validate(req); err != nil { + t.Errorf("Validate() should allow unchanged immutable field, got error: %v", err) + } +} + +func TestValidator_Validate_FeatureGates(t *testing.T) { + tests := []struct { + name string + fieldPath string + featureGate string + featureSet featuregate.FeatureSet + wantErr bool + errContains string + }{ + { + name: "gated field with Default feature set - blocked", + fieldPath: "spec.tags", + featureGate: "HyperFleetAutoScaling", + featureSet: featuregate.Default, + wantErr: true, + errContains: "requires feature gate HyperFleetAutoScaling", + }, + { + name: "gated field with TechPreview feature set - allowed", + fieldPath: "spec.tags", + featureGate: "HyperFleetAutoScaling", + featureSet: featuregate.TechPreviewNoUpgrade, + wantErr: false, + }, + { + name: "gated field with DevPreview feature set - allowed", + fieldPath: "spec.tags", + featureGate: "HyperFleetAutoScaling", + featureSet: featuregate.DevPreviewNoUpgrade, + wantErr: false, + }, + { + name: "non-gated field with Default feature set - allowed", + fieldPath: "spec.displayName", + featureSet: featuregate.Default, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &Validator{ + registry: map[string]registry.FieldMeta{ + tt.fieldPath: { + FieldPath: tt.fieldPath, + WriteMode: registry.Mutable, + FeatureGate: tt.featureGate, + }, + }, + } + + req := &Request{ + Operation: OperationCreate, + Fields: map[string]interface{}{tt.fieldPath: "test-value"}, + FeatureSet: tt.featureSet, + } + + err := v.Validate(req) + + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Validate() error = %v, want error containing %q", err, tt.errContains) + } + }) + } +} + +func TestValidator_Validate_MultipleErrors(t *testing.T) { + v := &Validator{ + registry: map[string]registry.FieldMeta{ + "spec.accountId": { + FieldPath: "spec.accountId", + WriteMode: registry.ServiceSet, + }, + "spec.internalId": { + FieldPath: "spec.internalId", + WriteMode: registry.ServiceSet, + }, + "spec.tags": { + FieldPath: "spec.tags", + WriteMode: registry.Mutable, + FeatureGate: "HyperFleetAutoScaling", + }, + }, + } + + req := &Request{ + Operation: OperationCreate, + Fields: map[string]interface{}{ + "spec.accountId": "test-account", + "spec.internalId": "test-id", + "spec.tags": map[string]string{"key": "value"}, + }, + FeatureSet: featuregate.Default, + } + + err := v.Validate(req) + if err == nil { + t.Fatal("Validate() expected error, got nil") + } + + errStr := err.Error() + + // Should have all three errors + if !strings.Contains(errStr, "spec.accountId") { + t.Error("expected error for spec.accountId") + } + if !strings.Contains(errStr, "spec.internalId") { + t.Error("expected error for spec.internalId") + } + if !strings.Contains(errStr, "spec.tags") { + t.Error("expected error for spec.tags") + } + if !strings.Contains(errStr, "service-set") { + t.Error("expected error mentioning service-set") + } + if !strings.Contains(errStr, "feature gate") { + t.Error("expected error mentioning feature gate") + } +} + +func TestValidator_ValidateFieldAccess(t *testing.T) { + v := &Validator{ + registry: map[string]registry.FieldMeta{ + "spec.tags": { + FieldPath: "spec.tags", + WriteMode: registry.Mutable, + FeatureGate: "HyperFleetAutoScaling", + }, + "spec.displayName": { + FieldPath: "spec.displayName", + WriteMode: registry.Mutable, + }, + }, + } + + tests := []struct { + name string + fieldPath string + featureSet featuregate.FeatureSet + wantErr bool + }{ + { + name: "gated field with insufficient feature set", + fieldPath: "spec.tags", + featureSet: featuregate.Default, + wantErr: true, + }, + { + name: "gated field with sufficient feature set", + fieldPath: "spec.tags", + featureSet: featuregate.TechPreviewNoUpgrade, + wantErr: false, + }, + { + name: "non-gated field", + fieldPath: "spec.displayName", + featureSet: featuregate.Default, + wantErr: false, + }, + { + name: "unknown field - allowed", + fieldPath: "spec.unknown", + featureSet: featuregate.Default, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := v.ValidateFieldAccess(tt.fieldPath, tt.featureSet) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateFieldAccess() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidator_GetFieldMetadata(t *testing.T) { + v := &Validator{ + registry: map[string]registry.FieldMeta{ + "spec.name": { + FieldPath: "spec.name", + WriteMode: registry.Immutable, + }, + }, + } + + // Field exists + meta, exists := v.GetFieldMetadata("spec.name") + if !exists { + t.Error("expected field to exist") + } + if meta.WriteMode != registry.Immutable { + t.Errorf("expected WriteMode=Immutable, got %v", meta.WriteMode) + } + + // Field doesn't exist + _, exists = v.GetFieldMetadata("spec.unknown") + if exists { + t.Error("expected field to not exist") + } +} + +func TestNewValidator_UsesGeneratedRegistry(t *testing.T) { + v := NewValidator() + if v == nil { + t.Fatal("NewValidator() returned nil") + } + + // Verify it's using the real generated registry by checking a known field + // This tests that the integration with pkg/registry works + meta, exists := v.GetFieldMetadata("spec.displayName") + if !exists { + t.Error("expected spec.displayName to exist in generated registry") + } + if meta.WriteMode != registry.Mutable { + t.Errorf("expected spec.displayName to be Mutable, got %v", meta.WriteMode) + } +} + +func TestValidationErrors_Error(t *testing.T) { + tests := []struct { + name string + errors ValidationErrors + want string + }{ + { + name: "empty errors", + errors: ValidationErrors{}, + want: "no validation errors", + }, + { + name: "single error", + errors: ValidationErrors{ + {FieldPath: "spec.name", Reason: "is required"}, + }, + want: "field spec.name: is required", + }, + { + name: "multiple errors", + errors: ValidationErrors{ + {FieldPath: "spec.name", Reason: "is required"}, + {FieldPath: "spec.region", Reason: "is invalid"}, + }, + want: "validation failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.errors.Error() + if !strings.Contains(got, tt.want) { + t.Errorf("Error() = %q, want containing %q", got, tt.want) + } + }) + } +} diff --git a/hack/tools/go.mod b/hack/tools/go.mod index 9f22f826..a127202c 100644 --- a/hack/tools/go.mod +++ b/hack/tools/go.mod @@ -1,6 +1,6 @@ module github.com/openshift-online/rosa-hyperfleet-api/hack/tools -go 1.26.0 +go 1.26.3 require ( github.com/golangci/golangci-lint/v2 v2.12.2 diff --git a/hyperfleet-operator/Containerfile b/hyperfleet-operator/Containerfile index c774368e..0323b280 100644 --- a/hyperfleet-operator/Containerfile +++ b/hyperfleet-operator/Containerfile @@ -3,8 +3,9 @@ ARG TARGETOS=linux ARG TARGETARCH=amd64 USER 0 -RUN mkdir -p /workspace && chown 1001:0 /workspace +RUN mkdir -p /workspace /tmp/gocache && chown 1001:0 /workspace /tmp/gocache USER 1001 +ENV GOCACHE=/tmp/gocache WORKDIR /workspace diff --git a/hyperfleet-operator/api/go.mod b/hyperfleet-operator/api/go.mod index 096ae987..574ed54b 100644 --- a/hyperfleet-operator/api/go.mod +++ b/hyperfleet-operator/api/go.mod @@ -3,7 +3,9 @@ module github.com/openshift-online/rosa-hyperfleet-api/hyperfleet-operator/api go 1.26.3 require ( + github.com/openshift/api v0.0.0-20260416105050-3c6b218b8a80 github.com/openshift/hypershift/api v0.0.0-20260625052409-9acec4759a16 + k8s.io/api v0.35.1 k8s.io/apimachinery v0.36.0 ) @@ -13,13 +15,11 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/openshift/api v0.0.0-20260416105050-3c6b218b8a80 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/text v0.38.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/api v0.35.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect diff --git a/hyperfleet-operator/api/v1alpha1/cluster_types.go b/hyperfleet-operator/api/v1alpha1/cluster_types.go index f9e20a6e..5120f8d4 100644 --- a/hyperfleet-operator/api/v1alpha1/cluster_types.go +++ b/hyperfleet-operator/api/v1alpha1/cluster_types.go @@ -37,17 +37,56 @@ const ( // metadata.Name is the human-readable cluster name; metadata.Namespace is the cluster UUID. // The owning AWS account is stored as the label hyperfleet.io/account-id. type ClusterSpec struct { + // === HyperFleet Envelope Fields === + + // DisplayName is a human-readable name for the cluster. + // +hyperfleet:write-mode=mutable + // +kubebuilder:validation:MaxLength=256 + // +optional + DisplayName string `json:"displayName,omitempty"` + + // DeleteProtection prevents accidental deletion when enabled. + // +hyperfleet:write-mode=mutable + // +optional + DeleteProtection *bool `json:"deleteProtection,omitempty"` + + // ExpirationTimestamp marks when this cluster should be automatically deleted. + // +hyperfleet:write-mode=mutable + // +optional + ExpirationTimestamp *metav1.Time `json:"expirationTimestamp,omitempty"` + + // Properties are arbitrary key-value pairs for customer metadata. + // +hyperfleet:write-mode=mutable + // +optional + Properties map[string]string `json:"properties,omitempty"` + + // Tags are customer-defined labels for organizational purposes. + // +hyperfleet:write-mode=mutable + // +openshift:enable:FeatureGate=HyperFleetAutoScaling + // +optional + Tags map[string]string `json:"tags,omitempty"` + + // AccountID identifies the customer account (platform-managed, hidden from API). + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + // +optional + AccountID string `json:"accountId,omitempty"` + // CreatorARN is the IAM ARN of the user who created this cluster. // Used to bootstrap the initial cluster-admin RBAC mapping. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set // +optional // +kubebuilder:validation:Pattern=`^arn:aws:` CreatorARN string `json:"creatorARN,omitempty"` - // ExpirationTimestamp is the time after which the cluster will be - // automatically deleted. If nil, the cluster has no expiration. + // InternalID is an internal platform identifier (platform-managed, hidden). + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set // +optional - ExpirationTimestamp *metav1.Time `json:"expirationTimestamp,omitempty"` + InternalID string `json:"internalId,omitempty"` + // === HyperShift Passthrough === // HostedCluster is the full HyperShift HostedClusterSpec. The customer provides // the fields they care about; the operator overrides platform-managed fields // (InfraID, DNS, PullSecret, Services, etc.) at render time. diff --git a/hyperfleet-operator/api/v1alpha1/configuration.go b/hyperfleet-operator/api/v1alpha1/configuration.go new file mode 100644 index 00000000..1a9324e6 --- /dev/null +++ b/hyperfleet-operator/api/v1alpha1/configuration.go @@ -0,0 +1,317 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ClusterConfiguration specifies configuration for individual OCP components in the cluster. +// This is a HyperFleet-owned mirror of hypershiftv1beta1.ClusterConfiguration that allows +// us to add granular markers to nested fields like kubelet config. +type ClusterConfiguration struct { + // apiServer contains advanced network settings for the API server. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + APIServer *APIServerNetworkConfiguration `json:"apiServer,omitempty"` + + // authentication contains configuration for the cluster authentication. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Authentication *ClusterAuthentication `json:"authentication,omitempty"` + + // featureGate contains the desired configuration for feature gates. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + FeatureGate *FeatureGateConfiguration `json:"featureGate,omitempty"` + + // image contains the configuration for internal registry. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Image *ImageConfiguration `json:"image,omitempty"` + + // ingress contains the configuration for ingress. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Ingress *IngressConfiguration `json:"ingress,omitempty"` + + // network contains the configuration for cluster networking. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Network *NetworkConfiguration `json:"network,omitempty"` + + // oauth contains the configuration for OAuth. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + OAuth *OAuthConfiguration `json:"oauth,omitempty"` + + // scheduler contains the configuration for scheduler. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Scheduler *SchedulerConfiguration `json:"scheduler,omitempty"` + + // proxy contains the configuration for the cluster-wide proxy. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Proxy *ProxyConfiguration `json:"proxy,omitempty"` + + // kubelet contains the configuration for kubelet on nodes. + // This is where we can add granular control over kubelet fields. + // +hyperfleet:write-mode=service-set + Kubelet *KubeletConfig `json:"kubelet,omitempty"` + + // machineConfig contains the configuration for machine-level settings (kernel params, systemd, files). + // Granular markers allow safe subset exposure while hiding dangerous operations. + // +hyperfleet:write-mode=service-set + MachineConfig *MachineConfigSpec `json:"machineConfig,omitempty"` +} + +// KubeletConfig specifies kubelet configuration. +// This is a HyperFleet-owned type that mirrors hypershiftv1beta1.KubeletConfig +// with granular markers for customer control. +type KubeletConfig struct { + // maxPods is the maximum number of pods per node. + // Customers can set this to optimize for high-density workloads. + // +hyperfleet:write-mode=mutable + MaxPods *int32 `json:"maxPods,omitempty"` + + // podPidsLimit is the maximum number of PIDs allowed per pod. + // Customers can increase this for applications that spawn many processes. + // +hyperfleet:write-mode=mutable + PodPidsLimit *int64 `json:"podPidsLimit,omitempty"` + + // systemReserved specifies resources reserved for system daemons. + // Customers can set this on cluster creation but cannot change it later. + // +hyperfleet:write-mode=immutable + SystemReserved map[string]string `json:"systemReserved,omitempty"` + + // kubeReserved specifies resources reserved for Kubernetes system components. + // +hyperfleet:write-mode=immutable + KubeReserved map[string]string `json:"kubeReserved,omitempty"` + + // evictionHard specifies hard eviction thresholds. + // Platform manages this for cluster stability and safety. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + EvictionHard map[string]string `json:"evictionHard,omitempty"` + + // evictionSoft specifies soft eviction thresholds. + // Platform manages this for cluster stability. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + EvictionSoft map[string]string `json:"evictionSoft,omitempty"` + + // evictionSoftGracePeriod specifies grace periods for soft evictions. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + EvictionSoftGracePeriod map[string]string `json:"evictionSoftGracePeriod,omitempty"` + + // imageGCHighThresholdPercent is the disk usage percent triggering image GC. + // +hyperfleet:write-mode=mutable + ImageGCHighThresholdPercent *int32 `json:"imageGCHighThresholdPercent,omitempty"` + + // imageGCLowThresholdPercent is the disk usage percent to gc to. + // +hyperfleet:write-mode=mutable + ImageGCLowThresholdPercent *int32 `json:"imageGCLowThresholdPercent,omitempty"` + + // imageMinimumGCAge is the minimum age for an unused image before it is garbage collected. + // +hyperfleet:write-mode=mutable + ImageMinimumGCAge *metav1.Duration `json:"imageMinimumGCAge,omitempty"` + + // serializeImagePulls when enabled, tells kubelet to pull images one at a time. + // Tech preview feature for optimizing image pull performance. + // +openshift:enable:FeatureGate=HyperFleetKubeletAdvanced + // +hyperfleet:write-mode=mutable + SerializeImagePulls *bool `json:"serializeImagePulls,omitempty"` + + // registryPullQPS is the limit of registry pulls per second. + // +openshift:enable:FeatureGate=HyperFleetKubeletAdvanced + // +hyperfleet:write-mode=mutable + RegistryPullQPS *int32 `json:"registryPullQPS,omitempty"` + + // registryBurst is the maximum size of bursty pulls, temporarily allows pulls to burst. + // +openshift:enable:FeatureGate=HyperFleetKubeletAdvanced + // +hyperfleet:write-mode=mutable + RegistryBurst *int32 `json:"registryBurst,omitempty"` + + // cpuManagerPolicy is the CPU management policy. + // Platform controls this to ensure consistent behavior. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + CPUManagerPolicy *string `json:"cpuManagerPolicy,omitempty"` + + // cpuManagerPolicyOptions is a set of key=value CPU manager policy options. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + CPUManagerPolicyOptions map[string]string `json:"cpuManagerPolicyOptions,omitempty"` + + // cpuManagerReconcilePeriod is the reconciliation period for the CPU manager. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + CPUManagerReconcilePeriod *metav1.Duration `json:"cpuManagerReconcilePeriod,omitempty"` + + // topologyManagerPolicy is the topology management policy. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + TopologyManagerPolicy *string `json:"topologyManagerPolicy,omitempty"` + + // topologyManagerScope represents the scope of topology hint generation. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + TopologyManagerScope *string `json:"topologyManagerScope,omitempty"` + + // allowedUnsafeSysctls are passed to the kubelet config to explicitly allow certain unsafe sysctls. + // Platform controls the allowlist for security. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + AllowedUnsafeSysctls []string `json:"allowedUnsafeSysctls,omitempty"` + + // streamingConnectionIdleTimeout is the maximum time a streaming connection can be idle. + // +hyperfleet:write-mode=mutable + StreamingConnectionIdleTimeout *metav1.Duration `json:"streamingConnectionIdleTimeout,omitempty"` + + // containerLogMaxSize is the maximum size of container log file before it is rotated. + // +hyperfleet:write-mode=mutable + ContainerLogMaxSize *string `json:"containerLogMaxSize,omitempty"` + + // containerLogMaxFiles is the maximum number of container log files. + // +hyperfleet:write-mode=mutable + ContainerLogMaxFiles *int32 `json:"containerLogMaxFiles,omitempty"` + + // memoryThrottlingFactor specifies the factor multiplied by the memory limit. + // Platform manages this for performance and stability. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + MemoryThrottlingFactor *float64 `json:"memoryThrottlingFactor,omitempty"` +} + +// Placeholder types for other configuration areas +// These would be fully defined similarly to KubeletConfig + +type APIServerNetworkConfiguration struct { + // TODO: Define fields with markers +} + +type ClusterAuthentication struct { + // TODO: Define fields with markers +} + +type FeatureGateConfiguration struct { + // TODO: Define fields with markers +} + +type ImageConfiguration struct { + // TODO: Define fields with markers +} + +type IngressConfiguration struct { + // TODO: Define fields with markers +} + +type NetworkConfiguration struct { + // TODO: Define fields with markers +} + +type OAuthConfiguration struct { + // TODO: Define fields with markers +} + +type SchedulerConfiguration struct { + // TODO: Define fields with markers +} + +type ProxyConfiguration struct { + // TODO: Define fields with markers +} + +// MachineConfigSpec specifies machine-level configuration. +// This controls kernel parameters, systemd units, and file writes. +// Most fields are platform-managed for security and stability. +type MachineConfigSpec struct { + // allowedKernelArguments specifies kernel parameters customers can request. + // This is a WHITELIST approach - customers can only request known-safe parameters. + // Platform validates against an allowlist and applies approved parameters. + // Tech Preview feature requiring explicit enablement. + // +openshift:enable:FeatureGate=HyperFleetMachineConfig + // +hyperfleet:write-mode=immutable + AllowedKernelArguments []string `json:"allowedKernelArguments,omitempty"` + + // kernelArguments are the actual kernel parameters applied to nodes. + // Platform manages the final list based on AllowedKernelArguments and platform defaults. + // Hidden from customers - they request via AllowedKernelArguments, platform sets this. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + KernelArguments []string `json:"kernelArguments,omitempty"` + + // systemdUnits are systemd units to configure on nodes. + // Platform-only for security - arbitrary systemd units are dangerous. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + SystemdUnits []SystemdUnit `json:"systemdUnits,omitempty"` + + // files are file writes to perform on nodes. + // Platform-only for security - arbitrary file writes are dangerous. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Files []FileSpec `json:"files,omitempty"` + + // fips enables FIPS mode on nodes. + // Immutable - must be set at cluster creation, cannot be changed. + // +hyperfleet:write-mode=immutable + FIPS *bool `json:"fips,omitempty"` + + // kernelType specifies the kernel variant (default, realtime). + // Platform manages this for consistency and support. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + KernelType *string `json:"kernelType,omitempty"` + + // extensions are additional software to install on nodes (e.g., usbguard, sandboxed-containers). + // Platform manages the allowed extension list. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Extensions []string `json:"extensions,omitempty"` +} + +// SystemdUnit represents a systemd unit configuration. +type SystemdUnit struct { + // name is the name of the systemd unit (e.g., "custom.service") + Name string `json:"name"` + + // enabled specifies whether the unit is enabled + Enabled *bool `json:"enabled,omitempty"` + + // contents is the full systemd unit file contents + Contents string `json:"contents,omitempty"` + + // dropins are drop-in configurations for the unit + Dropins []SystemdDropin `json:"dropins,omitempty"` +} + +// SystemdDropin represents a systemd drop-in configuration. +type SystemdDropin struct { + // name is the name of the drop-in file + Name string `json:"name"` + + // contents is the drop-in file contents + Contents string `json:"contents,omitempty"` +} + +// FileSpec represents a file to write to nodes. +type FileSpec struct { + // path is the absolute path where the file should be written + Path string `json:"path"` + + // contents is the file contents + Contents string `json:"contents,omitempty"` + + // mode is the file permissions (e.g., 0644) + Mode *int32 `json:"mode,omitempty"` + + // user is the file owner user + User *string `json:"user,omitempty"` + + // group is the file owner group + Group *string `json:"group,omitempty"` + + // overwrite specifies whether to overwrite existing files + Overwrite *bool `json:"overwrite,omitempty"` +} diff --git a/hyperfleet-operator/api/v1alpha1/hostedclusterspec.passthrough.go b/hyperfleet-operator/api/v1alpha1/hostedclusterspec.passthrough.go new file mode 100644 index 00000000..15c1f75f --- /dev/null +++ b/hyperfleet-operator/api/v1alpha1/hostedclusterspec.passthrough.go @@ -0,0 +1,204 @@ +// Code generated by passthrough-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + configv1 "github.com/openshift/api/config/v1" + hypershiftv1beta1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// HostedClusterSpecPassthrough mirrors HostedClusterSpec from upstream HyperShift +type HostedClusterSpecPassthrough struct { + // release specifies the desired OCP release payload for all the hosted cluster components. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Release hypershiftv1beta1.Release `json:"release"` + // controlPlaneRelease is like spec.release but only for the components running on the management cluster. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + ControlPlaneRelease *hypershiftv1beta1.Release `json:"controlPlaneRelease,omitempty"` + // clusterID uniquely identifies this cluster. This is expected to be an RFC4122 UUID value (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx in hexadecimal digits). + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + ClusterID string `json:"clusterID,omitempty"` + // infraID is a globally unique identifier for the cluster. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + InfraID string `json:"infraID,omitempty"` + // updateService may be used to specify the preferred upstream update service. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + UpdateService configv1.URL `json:"updateService,omitempty"` + // channel is an identifier for explicitly requesting that a non-default set of updates be applied to this cluster. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Channel string `json:"channel,omitempty"` + // platform specifies the underlying infrastructure provider for the cluster + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Platform hypershiftv1beta1.PlatformSpec `json:"platform"` + // kubeAPIServerDNSName specifies a desired DNS name to resolve to the KAS. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + KubeAPIServerDNSName string `json:"kubeAPIServerDNSName,omitempty"` + // controllerAvailabilityPolicy specifies the availability policy applied to critical control plane components like the Kube API Server. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + ControllerAvailabilityPolicy hypershiftv1beta1.AvailabilityPolicy `json:"controllerAvailabilityPolicy,omitempty"` + // infrastructureAvailabilityPolicy specifies the availability policy applied to infrastructure services which run on the hosted cluster data plane like the ingress controller and image registry controller. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + InfrastructureAvailabilityPolicy hypershiftv1beta1.AvailabilityPolicy `json:"infrastructureAvailabilityPolicy,omitempty"` + // dns specifies the DNS configuration for the hosted cluster ingress. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + DNS hypershiftv1beta1.DNSSpec `json:"dns,omitempty"` + // networking specifies network configuration for the hosted cluster. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Networking hypershiftv1beta1.ClusterNetworking `json:"networking"` + // autoscaling specifies auto-scaling behavior that applies to all NodePools + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Autoscaling hypershiftv1beta1.ClusterAutoscaling `json:"autoscaling,omitempty"` + // autoNode specifies the configuration for automatic node provisioning and lifecycle management. + // +hyperfleet:write-mode=service-set + AutoNode hypershiftv1beta1.AutoNode `json:"autoNode,omitzero"` + // etcd specifies configuration for the control plane etcd cluster. The + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Etcd hypershiftv1beta1.EtcdSpec `json:"etcd"` + // services specifies how individual control plane services endpoints are published for consumption. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Services []hypershiftv1beta1.ServicePublishingStrategyMapping `json:"services"` + // pullSecret is a local reference to a Secret that must have a ".dockerconfigjson" key whose content must be a valid Openshift pull secret JSON. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + PullSecret corev1.LocalObjectReference `json:"pullSecret"` + // sshKey is a local reference to a Secret that must have a "id_rsa.pub" key whose content must be the public part of 1..N SSH keys. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + SSHKey corev1.LocalObjectReference `json:"sshKey"` + // issuerURL is an OIDC issuer URL which will be used as the issuer in all + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + IssuerURL string `json:"issuerURL,omitempty"` + // serviceAccountSigningKey is a local reference to a secret that must have a "key" key whose content must be the private key + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + ServiceAccountSigningKey *corev1.LocalObjectReference `json:"serviceAccountSigningKey,omitempty"` + // configuration specifies configuration for individual OCP components in the + // +hyperfleet:write-mode=service-set + Configuration *hypershiftv1beta1.ClusterConfiguration `json:"configuration,omitempty"` + // operatorConfiguration specifies configuration for individual OCP operators in the cluster. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + OperatorConfiguration *hypershiftv1beta1.OperatorConfiguration `json:"operatorConfiguration,omitempty"` + // auditWebhook contains metadata for configuring an audit webhook endpoint + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + AuditWebhook *corev1.LocalObjectReference `json:"auditWebhook,omitempty"` + // imageContentSources specifies image mirrors that can be used by cluster + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + ImageContentSources []hypershiftv1beta1.ImageContentSource `json:"imageContentSources,omitempty"` + // additionalTrustBundle is a local reference to a ConfigMap that must have a "ca-bundle.crt" key + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + AdditionalTrustBundle *corev1.LocalObjectReference `json:"additionalTrustBundle,omitempty"` + // secretEncryption specifies a Kubernetes secret encryption strategy for the + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + SecretEncryption *hypershiftv1beta1.SecretEncryptionSpec `json:"secretEncryption,omitempty"` + // fips indicates whether this cluster's nodes will be running in FIPS mode. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + FIPS bool `json:"fips"` + // pausedUntil is a field that can be used to pause reconciliation on the HostedCluster controller, resulting in any change to the HostedCluster being ignored. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + PausedUntil *string `json:"pausedUntil,omitempty"` + // olmCatalogPlacement specifies the placement of OLM catalog components. By default, + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + OLMCatalogPlacement hypershiftv1beta1.OLMCatalogPlacement `json:"olmCatalogPlacement,omitempty"` + // nodeSelector when specified, is propagated to all control plane Deployments and Stateful sets running management side. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + // tolerations when specified, define what custom tolerations are added to the hcp pods. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + // labels when specified, define what custom labels are added to the hcp pods. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Labels map[string]string `json:"labels,omitempty"` + // capabilities allows for disabling optional components at cluster install time. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Capabilities *hypershiftv1beta1.Capabilities `json:"capabilities,omitempty"` +} + +// NodePoolSpecPassthrough mirrors NodePoolSpec from upstream HyperShift +type NodePoolSpecPassthrough struct { + // clusterName is the name of the HostedCluster this NodePool belongs to. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + ClusterName string `json:"clusterName"` + // release specifies the OCP release used for this NodePool. It drives the machine ignition configuration (including + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Release hypershiftv1beta1.Release `json:"release"` + // platform specifies the underlying infrastructure provider for the NodePool + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Platform hypershiftv1beta1.NodePoolPlatform `json:"platform"` + // replicas is the desired number of nodes the pool should maintain. If unset, the controller default value is 0. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Replicas *int32 `json:"replicas,omitempty"` + // management specifies behavior for managing nodes in the pool, such as + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Management hypershiftv1beta1.NodePoolManagement `json:"management"` + // autoScaling specifies auto-scaling behavior for the NodePool. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + AutoScaling *hypershiftv1beta1.NodePoolAutoScaling `json:"autoScaling,omitempty"` + // config is a list of references to ConfigMaps containing serialized + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Config []corev1.LocalObjectReference `json:"config,omitempty"` + // nodeDrainTimeout is the maximum amount of time that the controller will spend on retrying to drain a node until it succeeds. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + NodeDrainTimeout *metav1.Duration `json:"nodeDrainTimeout,omitempty"` + // nodeVolumeDetachTimeout is the maximum amount of time that the controller will spend on detaching volumes from a node. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + NodeVolumeDetachTimeout *metav1.Duration `json:"nodeVolumeDetachTimeout,omitempty"` + // nodeLabels propagates a list of labels to Nodes, only once on creation. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + NodeLabels map[string]string `json:"nodeLabels,omitempty"` + // taints if specified, propagates a list of taints to Nodes, only once on creation. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Taints []hypershiftv1beta1.Taint `json:"taints,omitempty"` + // pausedUntil is a field that can be used to pause reconciliation on the NodePool controller. Resulting in any change to the NodePool being ignored. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + PausedUntil *string `json:"pausedUntil,omitempty"` + // tuningConfig is a list of references to ConfigMaps containing serialized + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + TuningConfig []corev1.LocalObjectReference `json:"tuningConfig,omitempty"` + // arch is the preferred processor architecture for the NodePool. Different platforms might have different supported architectures. + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + Arch string `json:"arch,omitempty"` +} diff --git a/hyperfleet-operator/api/v1alpha1/nodepool_types.go b/hyperfleet-operator/api/v1alpha1/nodepool_types.go index 0fa7c7e2..3de45ae3 100644 --- a/hyperfleet-operator/api/v1alpha1/nodepool_types.go +++ b/hyperfleet-operator/api/v1alpha1/nodepool_types.go @@ -36,6 +36,37 @@ const ( // NodePoolSpec defines the desired state of a NodePool. // The parent Cluster is identified by the shared metadata.Namespace (cluster UUID). type NodePoolSpec struct { + // === HyperFleet Envelope Fields === + + // DisplayName is a human-readable name for the node pool. + // +hyperfleet:write-mode=mutable + // +optional + DisplayName string `json:"displayName,omitempty"` + + // AutoRepair enables automatic repair of unhealthy nodes. + // +hyperfleet:write-mode=mutable + // +optional + AutoRepair *bool `json:"autoRepair,omitempty"` + + // Labels to apply to nodes in this pool. + // +hyperfleet:write-mode=mutable + // +optional + Labels map[string]string `json:"labels,omitempty"` + + // AccountID identifies the customer account (platform-managed, hidden). + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + // +optional + AccountID string `json:"accountId,omitempty"` + + // InternalPoolID is an internal platform identifier (platform-managed, hidden). + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + // +optional + InternalPoolID string `json:"internalPoolId,omitempty"` + + // === HyperShift Passthrough === + // NodePool is the full HyperShift NodePoolSpec. The customer provides replicas, // platform, release, etc. The operator overrides ClusterName and adds system // resource tags at render time. diff --git a/hyperfleet-operator/api/v1alpha1/zz_generated.deepcopy.go b/hyperfleet-operator/api/v1alpha1/zz_generated.deepcopy.go index 7807487d..ab7a6f62 100644 --- a/hyperfleet-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/hyperfleet-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -5,10 +5,27 @@ package v1alpha1 import ( + "github.com/openshift/hypershift/api/hypershift/v1beta1" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIServerNetworkConfiguration) DeepCopyInto(out *APIServerNetworkConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIServerNetworkConfiguration. +func (in *APIServerNetworkConfiguration) DeepCopy() *APIServerNetworkConfiguration { + if in == nil { + return nil + } + out := new(APIServerNetworkConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Cluster) DeepCopyInto(out *Cluster) { *out = *in @@ -36,6 +53,91 @@ func (in *Cluster) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterAuthentication) DeepCopyInto(out *ClusterAuthentication) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterAuthentication. +func (in *ClusterAuthentication) DeepCopy() *ClusterAuthentication { + if in == nil { + return nil + } + out := new(ClusterAuthentication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterConfiguration) DeepCopyInto(out *ClusterConfiguration) { + *out = *in + if in.APIServer != nil { + in, out := &in.APIServer, &out.APIServer + *out = new(APIServerNetworkConfiguration) + **out = **in + } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(ClusterAuthentication) + **out = **in + } + if in.FeatureGate != nil { + in, out := &in.FeatureGate, &out.FeatureGate + *out = new(FeatureGateConfiguration) + **out = **in + } + if in.Image != nil { + in, out := &in.Image, &out.Image + *out = new(ImageConfiguration) + **out = **in + } + if in.Ingress != nil { + in, out := &in.Ingress, &out.Ingress + *out = new(IngressConfiguration) + **out = **in + } + if in.Network != nil { + in, out := &in.Network, &out.Network + *out = new(NetworkConfiguration) + **out = **in + } + if in.OAuth != nil { + in, out := &in.OAuth, &out.OAuth + *out = new(OAuthConfiguration) + **out = **in + } + if in.Scheduler != nil { + in, out := &in.Scheduler, &out.Scheduler + *out = new(SchedulerConfiguration) + **out = **in + } + if in.Proxy != nil { + in, out := &in.Proxy, &out.Proxy + *out = new(ProxyConfiguration) + **out = **in + } + if in.Kubelet != nil { + in, out := &in.Kubelet, &out.Kubelet + *out = new(KubeletConfig) + (*in).DeepCopyInto(*out) + } + if in.MachineConfig != nil { + in, out := &in.MachineConfig, &out.MachineConfig + *out = new(MachineConfigSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterConfiguration. +func (in *ClusterConfiguration) DeepCopy() *ClusterConfiguration { + if in == nil { + return nil + } + out := new(ClusterConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterList) DeepCopyInto(out *ClusterList) { *out = *in @@ -71,10 +173,29 @@ func (in *ClusterList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterSpec) DeepCopyInto(out *ClusterSpec) { *out = *in + if in.DeleteProtection != nil { + in, out := &in.DeleteProtection, &out.DeleteProtection + *out = new(bool) + **out = **in + } if in.ExpirationTimestamp != nil { in, out := &in.ExpirationTimestamp, &out.ExpirationTimestamp *out = (*in).DeepCopy() } + if in.Properties != nil { + in, out := &in.Properties, &out.Properties + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } in.HostedCluster.DeepCopyInto(&out.HostedCluster) } @@ -116,6 +237,386 @@ func (in *ClusterStatus) DeepCopy() *ClusterStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeatureGateConfiguration) DeepCopyInto(out *FeatureGateConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureGateConfiguration. +func (in *FeatureGateConfiguration) DeepCopy() *FeatureGateConfiguration { + if in == nil { + return nil + } + out := new(FeatureGateConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileSpec) DeepCopyInto(out *FileSpec) { + *out = *in + if in.Mode != nil { + in, out := &in.Mode, &out.Mode + *out = new(int32) + **out = **in + } + if in.User != nil { + in, out := &in.User, &out.User + *out = new(string) + **out = **in + } + if in.Group != nil { + in, out := &in.Group, &out.Group + *out = new(string) + **out = **in + } + if in.Overwrite != nil { + in, out := &in.Overwrite, &out.Overwrite + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSpec. +func (in *FileSpec) DeepCopy() *FileSpec { + if in == nil { + return nil + } + out := new(FileSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedClusterSpecPassthrough) DeepCopyInto(out *HostedClusterSpecPassthrough) { + *out = *in + out.Release = in.Release + if in.ControlPlaneRelease != nil { + in, out := &in.ControlPlaneRelease, &out.ControlPlaneRelease + *out = new(v1beta1.Release) + **out = **in + } + in.Platform.DeepCopyInto(&out.Platform) + in.DNS.DeepCopyInto(&out.DNS) + in.Networking.DeepCopyInto(&out.Networking) + in.Autoscaling.DeepCopyInto(&out.Autoscaling) + out.AutoNode = in.AutoNode + in.Etcd.DeepCopyInto(&out.Etcd) + if in.Services != nil { + in, out := &in.Services, &out.Services + *out = make([]v1beta1.ServicePublishingStrategyMapping, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.PullSecret = in.PullSecret + out.SSHKey = in.SSHKey + if in.ServiceAccountSigningKey != nil { + in, out := &in.ServiceAccountSigningKey, &out.ServiceAccountSigningKey + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.Configuration != nil { + in, out := &in.Configuration, &out.Configuration + *out = new(v1beta1.ClusterConfiguration) + (*in).DeepCopyInto(*out) + } + if in.OperatorConfiguration != nil { + in, out := &in.OperatorConfiguration, &out.OperatorConfiguration + *out = new(v1beta1.OperatorConfiguration) + (*in).DeepCopyInto(*out) + } + if in.AuditWebhook != nil { + in, out := &in.AuditWebhook, &out.AuditWebhook + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.ImageContentSources != nil { + in, out := &in.ImageContentSources, &out.ImageContentSources + *out = make([]v1beta1.ImageContentSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AdditionalTrustBundle != nil { + in, out := &in.AdditionalTrustBundle, &out.AdditionalTrustBundle + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.SecretEncryption != nil { + in, out := &in.SecretEncryption, &out.SecretEncryption + *out = new(v1beta1.SecretEncryptionSpec) + (*in).DeepCopyInto(*out) + } + if in.PausedUntil != nil { + in, out := &in.PausedUntil, &out.PausedUntil + *out = new(string) + **out = **in + } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = new(v1beta1.Capabilities) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedClusterSpecPassthrough. +func (in *HostedClusterSpecPassthrough) DeepCopy() *HostedClusterSpecPassthrough { + if in == nil { + return nil + } + out := new(HostedClusterSpecPassthrough) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageConfiguration) DeepCopyInto(out *ImageConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageConfiguration. +func (in *ImageConfiguration) DeepCopy() *ImageConfiguration { + if in == nil { + return nil + } + out := new(ImageConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressConfiguration) DeepCopyInto(out *IngressConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressConfiguration. +func (in *IngressConfiguration) DeepCopy() *IngressConfiguration { + if in == nil { + return nil + } + out := new(IngressConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeletConfig) DeepCopyInto(out *KubeletConfig) { + *out = *in + if in.MaxPods != nil { + in, out := &in.MaxPods, &out.MaxPods + *out = new(int32) + **out = **in + } + if in.PodPidsLimit != nil { + in, out := &in.PodPidsLimit, &out.PodPidsLimit + *out = new(int64) + **out = **in + } + if in.SystemReserved != nil { + in, out := &in.SystemReserved, &out.SystemReserved + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.KubeReserved != nil { + in, out := &in.KubeReserved, &out.KubeReserved + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.EvictionHard != nil { + in, out := &in.EvictionHard, &out.EvictionHard + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.EvictionSoft != nil { + in, out := &in.EvictionSoft, &out.EvictionSoft + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.EvictionSoftGracePeriod != nil { + in, out := &in.EvictionSoftGracePeriod, &out.EvictionSoftGracePeriod + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ImageGCHighThresholdPercent != nil { + in, out := &in.ImageGCHighThresholdPercent, &out.ImageGCHighThresholdPercent + *out = new(int32) + **out = **in + } + if in.ImageGCLowThresholdPercent != nil { + in, out := &in.ImageGCLowThresholdPercent, &out.ImageGCLowThresholdPercent + *out = new(int32) + **out = **in + } + if in.ImageMinimumGCAge != nil { + in, out := &in.ImageMinimumGCAge, &out.ImageMinimumGCAge + *out = new(v1.Duration) + **out = **in + } + if in.SerializeImagePulls != nil { + in, out := &in.SerializeImagePulls, &out.SerializeImagePulls + *out = new(bool) + **out = **in + } + if in.RegistryPullQPS != nil { + in, out := &in.RegistryPullQPS, &out.RegistryPullQPS + *out = new(int32) + **out = **in + } + if in.RegistryBurst != nil { + in, out := &in.RegistryBurst, &out.RegistryBurst + *out = new(int32) + **out = **in + } + if in.CPUManagerPolicy != nil { + in, out := &in.CPUManagerPolicy, &out.CPUManagerPolicy + *out = new(string) + **out = **in + } + if in.CPUManagerPolicyOptions != nil { + in, out := &in.CPUManagerPolicyOptions, &out.CPUManagerPolicyOptions + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.CPUManagerReconcilePeriod != nil { + in, out := &in.CPUManagerReconcilePeriod, &out.CPUManagerReconcilePeriod + *out = new(v1.Duration) + **out = **in + } + if in.TopologyManagerPolicy != nil { + in, out := &in.TopologyManagerPolicy, &out.TopologyManagerPolicy + *out = new(string) + **out = **in + } + if in.TopologyManagerScope != nil { + in, out := &in.TopologyManagerScope, &out.TopologyManagerScope + *out = new(string) + **out = **in + } + if in.AllowedUnsafeSysctls != nil { + in, out := &in.AllowedUnsafeSysctls, &out.AllowedUnsafeSysctls + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.StreamingConnectionIdleTimeout != nil { + in, out := &in.StreamingConnectionIdleTimeout, &out.StreamingConnectionIdleTimeout + *out = new(v1.Duration) + **out = **in + } + if in.ContainerLogMaxSize != nil { + in, out := &in.ContainerLogMaxSize, &out.ContainerLogMaxSize + *out = new(string) + **out = **in + } + if in.ContainerLogMaxFiles != nil { + in, out := &in.ContainerLogMaxFiles, &out.ContainerLogMaxFiles + *out = new(int32) + **out = **in + } + if in.MemoryThrottlingFactor != nil { + in, out := &in.MemoryThrottlingFactor, &out.MemoryThrottlingFactor + *out = new(float64) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeletConfig. +func (in *KubeletConfig) DeepCopy() *KubeletConfig { + if in == nil { + return nil + } + out := new(KubeletConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MachineConfigSpec) DeepCopyInto(out *MachineConfigSpec) { + *out = *in + if in.AllowedKernelArguments != nil { + in, out := &in.AllowedKernelArguments, &out.AllowedKernelArguments + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.KernelArguments != nil { + in, out := &in.KernelArguments, &out.KernelArguments + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SystemdUnits != nil { + in, out := &in.SystemdUnits, &out.SystemdUnits + *out = make([]SystemdUnit, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Files != nil { + in, out := &in.Files, &out.Files + *out = make([]FileSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.FIPS != nil { + in, out := &in.FIPS, &out.FIPS + *out = new(bool) + **out = **in + } + if in.KernelType != nil { + in, out := &in.KernelType, &out.KernelType + *out = new(string) + **out = **in + } + if in.Extensions != nil { + in, out := &in.Extensions, &out.Extensions + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineConfigSpec. +func (in *MachineConfigSpec) DeepCopy() *MachineConfigSpec { + if in == nil { + return nil + } + out := new(MachineConfigSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagementCluster) DeepCopyInto(out *ManagementCluster) { *out = *in @@ -322,6 +823,21 @@ func (in *ManifestStatus) DeepCopy() *ManifestStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkConfiguration) DeepCopyInto(out *NetworkConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkConfiguration. +func (in *NetworkConfiguration) DeepCopy() *NetworkConfiguration { + if in == nil { + return nil + } + out := new(NetworkConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NodePool) DeepCopyInto(out *NodePool) { *out = *in @@ -384,6 +900,18 @@ func (in *NodePoolList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NodePoolSpec) DeepCopyInto(out *NodePoolSpec) { *out = *in + if in.AutoRepair != nil { + in, out := &in.AutoRepair, &out.AutoRepair + *out = new(bool) + **out = **in + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } in.NodePool.DeepCopyInto(&out.NodePool) } @@ -397,6 +925,71 @@ func (in *NodePoolSpec) DeepCopy() *NodePoolSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodePoolSpecPassthrough) DeepCopyInto(out *NodePoolSpecPassthrough) { + *out = *in + out.Release = in.Release + in.Platform.DeepCopyInto(&out.Platform) + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + in.Management.DeepCopyInto(&out.Management) + if in.AutoScaling != nil { + in, out := &in.AutoScaling, &out.AutoScaling + *out = new(v1beta1.NodePoolAutoScaling) + (*in).DeepCopyInto(*out) + } + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = make([]corev1.LocalObjectReference, len(*in)) + copy(*out, *in) + } + if in.NodeDrainTimeout != nil { + in, out := &in.NodeDrainTimeout, &out.NodeDrainTimeout + *out = new(v1.Duration) + **out = **in + } + if in.NodeVolumeDetachTimeout != nil { + in, out := &in.NodeVolumeDetachTimeout, &out.NodeVolumeDetachTimeout + *out = new(v1.Duration) + **out = **in + } + if in.NodeLabels != nil { + in, out := &in.NodeLabels, &out.NodeLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Taints != nil { + in, out := &in.Taints, &out.Taints + *out = make([]v1beta1.Taint, len(*in)) + copy(*out, *in) + } + if in.PausedUntil != nil { + in, out := &in.PausedUntil, &out.PausedUntil + *out = new(string) + **out = **in + } + if in.TuningConfig != nil { + in, out := &in.TuningConfig, &out.TuningConfig + *out = make([]corev1.LocalObjectReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodePoolSpecPassthrough. +func (in *NodePoolSpecPassthrough) DeepCopy() *NodePoolSpecPassthrough { + if in == nil { + return nil + } + out := new(NodePoolSpecPassthrough) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NodePoolStatus) DeepCopyInto(out *NodePoolStatus) { *out = *in @@ -419,6 +1012,21 @@ func (in *NodePoolStatus) DeepCopy() *NodePoolStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OAuthConfiguration) DeepCopyInto(out *OAuthConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OAuthConfiguration. +func (in *OAuthConfiguration) DeepCopy() *OAuthConfiguration { + if in == nil { + return nil + } + out := new(OAuthConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Placement) DeepCopyInto(out *Placement) { *out = *in @@ -530,6 +1138,21 @@ func (in *PlacementStatus) DeepCopy() *PlacementStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProxyConfiguration) DeepCopyInto(out *ProxyConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyConfiguration. +func (in *ProxyConfiguration) DeepCopy() *ProxyConfiguration { + if in == nil { + return nil + } + out := new(ProxyConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceStatus) DeepCopyInto(out *ResourceStatus) { *out = *in @@ -561,3 +1184,58 @@ func (in *ResourceTemplate) DeepCopy() *ResourceTemplate { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SchedulerConfiguration) DeepCopyInto(out *SchedulerConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SchedulerConfiguration. +func (in *SchedulerConfiguration) DeepCopy() *SchedulerConfiguration { + if in == nil { + return nil + } + out := new(SchedulerConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SystemdDropin) DeepCopyInto(out *SystemdDropin) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SystemdDropin. +func (in *SystemdDropin) DeepCopy() *SystemdDropin { + if in == nil { + return nil + } + out := new(SystemdDropin) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SystemdUnit) DeepCopyInto(out *SystemdUnit) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } + if in.Dropins != nil { + in, out := &in.Dropins, &out.Dropins + *out = make([]SystemdDropin, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SystemdUnit. +func (in *SystemdUnit) DeepCopy() *SystemdUnit { + if in == nil { + return nil + } + out := new(SystemdUnit) + in.DeepCopyInto(out) + return out +} diff --git a/platform-api/internal/codegen/conversion/cluster.go b/platform-api/internal/codegen/conversion/cluster.go new file mode 100644 index 00000000..79d94085 --- /dev/null +++ b/platform-api/internal/codegen/conversion/cluster.go @@ -0,0 +1,32 @@ +package conversion + +import ( + v1alpha1 "github.com/openshift-online/rosa-hyperfleet-api/hyperfleet-operator/api/v1alpha1" +) + +// ClusterServiceSetFields holds platform-injected values for cluster creation. +type ClusterServiceSetFields struct { + CreatorARN string + IssuerURL string +} + +// InjectClusterServiceSet populates service-set fields on a ClusterSpec during creation. +// Only non-empty values are injected. +func InjectClusterServiceSet(spec *v1alpha1.ClusterSpec, ssf ClusterServiceSetFields) { + if ssf.CreatorARN != "" { + spec.CreatorARN = ssf.CreatorARN + } + if ssf.IssuerURL != "" { + spec.HostedCluster.IssuerURL = ssf.IssuerURL + } +} + +// PreserveClusterServiceSet restores service-set field values from a pre-update +// snapshot into the updated spec, preventing the full-spec replacement in +// ApplyPlatformUpdateToClusterCR from wiping platform-managed fields. +func PreserveClusterServiceSet(updated, snapshot *v1alpha1.ClusterSpec) { + updated.CreatorARN = snapshot.CreatorARN + updated.AccountID = snapshot.AccountID + updated.InternalID = snapshot.InternalID + updated.HostedCluster.IssuerURL = snapshot.HostedCluster.IssuerURL +} diff --git a/platform-api/internal/codegen/conversion/cluster_test.go b/platform-api/internal/codegen/conversion/cluster_test.go new file mode 100644 index 00000000..dbcd39a5 --- /dev/null +++ b/platform-api/internal/codegen/conversion/cluster_test.go @@ -0,0 +1,78 @@ +package conversion + +import ( + "testing" + + v1alpha1 "github.com/openshift-online/rosa-hyperfleet-api/hyperfleet-operator/api/v1alpha1" + hypershiftv1beta1 "github.com/openshift/hypershift/api/hypershift/v1beta1" +) + +func TestInjectClusterServiceSet(t *testing.T) { + spec := &v1alpha1.ClusterSpec{ + HostedCluster: hypershiftv1beta1.HostedClusterSpec{}, + } + + InjectClusterServiceSet(spec, ClusterServiceSetFields{ + CreatorARN: "arn:aws:iam::123456789:user/test", + IssuerURL: "https://oidc.example.com/cluster-abc", + }) + + if spec.CreatorARN != "arn:aws:iam::123456789:user/test" { + t.Errorf("expected CreatorARN to be set, got %q", spec.CreatorARN) + } + if spec.HostedCluster.IssuerURL != "https://oidc.example.com/cluster-abc" { + t.Errorf("expected IssuerURL to be set, got %q", spec.HostedCluster.IssuerURL) + } +} + +func TestInjectClusterServiceSet_EmptyValues(t *testing.T) { + spec := &v1alpha1.ClusterSpec{ + CreatorARN: "existing-arn", + HostedCluster: hypershiftv1beta1.HostedClusterSpec{ + IssuerURL: "existing-url", + }, + } + + InjectClusterServiceSet(spec, ClusterServiceSetFields{}) + + if spec.CreatorARN != "existing-arn" { + t.Errorf("expected CreatorARN to remain unchanged, got %q", spec.CreatorARN) + } + if spec.HostedCluster.IssuerURL != "existing-url" { + t.Errorf("expected IssuerURL to remain unchanged, got %q", spec.HostedCluster.IssuerURL) + } +} + +func TestPreserveClusterServiceSet(t *testing.T) { + snapshot := &v1alpha1.ClusterSpec{ + CreatorARN: "arn:aws:iam::123456789:user/creator", + AccountID: "acct-001", + InternalID: "internal-xyz", + HostedCluster: hypershiftv1beta1.HostedClusterSpec{ + IssuerURL: "https://oidc.example.com/cluster-abc", + }, + } + + updated := &v1alpha1.ClusterSpec{ + DisplayName: "updated-name", + HostedCluster: hypershiftv1beta1.HostedClusterSpec{}, + } + + PreserveClusterServiceSet(updated, snapshot) + + if updated.CreatorARN != "arn:aws:iam::123456789:user/creator" { + t.Errorf("expected CreatorARN restored, got %q", updated.CreatorARN) + } + if updated.AccountID != "acct-001" { + t.Errorf("expected AccountID restored, got %q", updated.AccountID) + } + if updated.InternalID != "internal-xyz" { + t.Errorf("expected InternalID restored, got %q", updated.InternalID) + } + if updated.HostedCluster.IssuerURL != "https://oidc.example.com/cluster-abc" { + t.Errorf("expected IssuerURL restored, got %q", updated.HostedCluster.IssuerURL) + } + if updated.DisplayName != "updated-name" { + t.Errorf("expected DisplayName preserved, got %q", updated.DisplayName) + } +} diff --git a/platform-api/internal/codegen/conversion/nodepool.go b/platform-api/internal/codegen/conversion/nodepool.go new file mode 100644 index 00000000..eec24326 --- /dev/null +++ b/platform-api/internal/codegen/conversion/nodepool.go @@ -0,0 +1,13 @@ +package conversion + +import ( + v1alpha1 "github.com/openshift-online/rosa-hyperfleet-api/hyperfleet-operator/api/v1alpha1" +) + +// PreserveNodePoolServiceSet restores service-set field values from a pre-update +// snapshot into the updated spec, preventing the full-spec replacement in +// ApplyPlatformUpdateToNodePoolCR from wiping platform-managed fields. +func PreserveNodePoolServiceSet(updated, snapshot *v1alpha1.NodePoolSpec) { + updated.AccountID = snapshot.AccountID + updated.InternalPoolID = snapshot.InternalPoolID +} diff --git a/platform-api/internal/codegen/conversion/nodepool_test.go b/platform-api/internal/codegen/conversion/nodepool_test.go new file mode 100644 index 00000000..1b628e68 --- /dev/null +++ b/platform-api/internal/codegen/conversion/nodepool_test.go @@ -0,0 +1,31 @@ +package conversion + +import ( + "testing" + + v1alpha1 "github.com/openshift-online/rosa-hyperfleet-api/hyperfleet-operator/api/v1alpha1" +) + +func TestPreserveNodePoolServiceSet(t *testing.T) { + snapshot := &v1alpha1.NodePoolSpec{ + AccountID: "acct-001", + InternalPoolID: "pool-xyz", + DisplayName: "original-name", + } + + updated := &v1alpha1.NodePoolSpec{ + DisplayName: "updated-name", + } + + PreserveNodePoolServiceSet(updated, snapshot) + + if updated.AccountID != "acct-001" { + t.Errorf("expected AccountID restored, got %q", updated.AccountID) + } + if updated.InternalPoolID != "pool-xyz" { + t.Errorf("expected InternalPoolID restored, got %q", updated.InternalPoolID) + } + if updated.DisplayName != "updated-name" { + t.Errorf("expected DisplayName preserved, got %q", updated.DisplayName) + } +} diff --git a/platform-api/internal/codegen/featuregate/registry.go b/platform-api/internal/codegen/featuregate/registry.go new file mode 100644 index 00000000..1f65122b --- /dev/null +++ b/platform-api/internal/codegen/featuregate/registry.go @@ -0,0 +1,62 @@ +package featuregate + +// HyperFleetFeatureGates is the registry of all feature gates +// Each gate controls access to specific fields or capabilities +var HyperFleetFeatureGates = map[string]FeatureGateInfo{ + // Example gates - these would be populated based on actual product requirements + + "HyperFleetEtcdConfig": { + Stage: GA, + Description: "Allows customers to configure etcd settings", + }, + + "HyperFleetAutoScaling": { + Stage: TechPreview, + Description: "Enables cluster autoscaling configuration", + }, + + "HyperFleetSecretEncryption": { + Stage: TechPreview, + Description: "Allows customers to configure secret encryption", + }, + + "HyperFleetCustomDNS": { + Stage: DevPreview, + Description: "Enables custom DNS configuration for development/testing", + }, + + "HyperFleetKubeletAdvanced": { + Stage: TechPreview, + Description: "Enables advanced kubelet configuration (serializeImagePulls, registryPullQPS, etc.)", + }, + + "HyperFleetMachineConfig": { + Stage: TechPreview, + Description: "Allows customers to request approved kernel parameters via allowlist", + }, +} + +// IsGateEnabled returns true if the given gate is enabled for the feature set +func IsGateEnabled(gate string, featureSet FeatureSet) bool { + info, exists := HyperFleetFeatureGates[gate] + if !exists { + // Unknown gates are disabled by default + return false + } + + return featureSet.Includes(info.Stage) +} + +// GatesForFeatureSet returns all gates enabled for the given feature set +func GatesForFeatureSet(featureSet FeatureSet) []string { + var gates []string + maxStage := featureSet.MaxStage() + + for gate, info := range HyperFleetFeatureGates { + if info.Stage <= maxStage { + gates = append(gates, gate) + } + } + + return gates +} diff --git a/platform-api/internal/codegen/featuregate/types.go b/platform-api/internal/codegen/featuregate/types.go new file mode 100644 index 00000000..ec8e7454 --- /dev/null +++ b/platform-api/internal/codegen/featuregate/types.go @@ -0,0 +1,74 @@ +package featuregate + +// FeatureStage represents the maturity stage of a feature gate +type FeatureStage int + +const ( + // GA features are generally available to all customers + GA FeatureStage = iota + + // TechPreview features are available to customers who opt into tech preview + // Includes all GA features + TechPreview + + // DevPreview features are available only for development/testing + // Includes all GA and TechPreview features + DevPreview +) + +// String returns the string representation of a FeatureStage +func (s FeatureStage) String() string { + switch s { + case GA: + return "GA" + case TechPreview: + return "TechPreview" + case DevPreview: + return "DevPreview" + default: + return "Unknown" + } +} + +// FeatureGateInfo describes a single feature gate +type FeatureGateInfo struct { + // Stage is the maturity stage of this gate + Stage FeatureStage + + // Description explains what this gate controls + Description string +} + +// FeatureSet represents a collection of feature gates +type FeatureSet string + +const ( + // Default includes only GA features + Default FeatureSet = "Default" + + // TechPreviewNoUpgrade includes GA + TechPreview features + // "NoUpgrade" indicates customers cannot upgrade clusters with these features + TechPreviewNoUpgrade FeatureSet = "TechPreviewNoUpgrade" + + // DevPreviewNoUpgrade includes GA + TechPreview + DevPreview features + DevPreviewNoUpgrade FeatureSet = "DevPreviewNoUpgrade" +) + +// MaxStage returns the maximum feature stage included in this feature set +func (fs FeatureSet) MaxStage() FeatureStage { + switch fs { + case Default: + return GA + case TechPreviewNoUpgrade: + return TechPreview + case DevPreviewNoUpgrade: + return DevPreview + default: + return GA + } +} + +// Includes returns true if this feature set includes the given stage +func (fs FeatureSet) Includes(stage FeatureStage) bool { + return stage <= fs.MaxStage() +} diff --git a/platform-api/internal/codegen/registry/field_metadata.go b/platform-api/internal/codegen/registry/field_metadata.go new file mode 100644 index 00000000..7303b107 --- /dev/null +++ b/platform-api/internal/codegen/registry/field_metadata.go @@ -0,0 +1,330 @@ +// Code generated by marker-scanner. DO NOT EDIT. + +package registry + +// WriteMode defines how a field can be mutated by customers +type WriteMode string + +const ( + // Mutable fields can be set on create and changed on update + Mutable WriteMode = "mutable" + + // Immutable fields can be set on create but cannot be changed on update + Immutable WriteMode = "immutable" + + // ServiceSet fields are set by the platform and cannot be set by customers + ServiceSet WriteMode = "service-set" +) + +// FeatureGateWriteMode represents a write-mode override for a specific feature gate +type FeatureGateWriteMode struct { + // FeatureGate is the gate that enables this write-mode (empty string = default/no gates enabled) + FeatureGate string + + // WriteMode is the effective write-mode when this gate condition matches + WriteMode WriteMode +} + +// FieldMeta contains metadata for a single field +type FieldMeta struct { + // FieldPath is the JSON path to the field (e.g., "spec.name") + FieldPath string + + // WriteMode controls customer mutability + WriteMode WriteMode + + // FeatureGate is the gate required to use this field (empty if no gate required) + FeatureGate string + + // Hidden indicates if the field is excluded from OpenAPI + Hidden bool + + // FeatureGateAwareWriteModes allows write-mode to vary based on enabled feature gates + FeatureGateAwareWriteModes []FeatureGateWriteMode +} + +// FieldRegistry maps field paths to their metadata +var FieldRegistry = map[string]FieldMeta{ + "spec.accountId": { + FieldPath: "spec.accountId", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.autoRepair": { + FieldPath: "spec.autoRepair", + WriteMode: Mutable, + }, + "spec.creatorARN": { + FieldPath: "spec.creatorARN", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.deleteProtection": { + FieldPath: "spec.deleteProtection", + WriteMode: Mutable, + }, + "spec.displayName": { + FieldPath: "spec.displayName", + WriteMode: Mutable, + }, + "spec.expirationTimestamp": { + FieldPath: "spec.expirationTimestamp", + WriteMode: Mutable, + }, + "spec.hostedCluster.additionalTrustBundle": { + FieldPath: "spec.hostedCluster.additionalTrustBundle", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.auditWebhook": { + FieldPath: "spec.hostedCluster.auditWebhook", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.autoNode": { + FieldPath: "spec.hostedCluster.autoNode", + WriteMode: ServiceSet, + }, + "spec.hostedCluster.autoscaling": { + FieldPath: "spec.hostedCluster.autoscaling", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.capabilities": { + FieldPath: "spec.hostedCluster.capabilities", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.channel": { + FieldPath: "spec.hostedCluster.channel", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.clusterID": { + FieldPath: "spec.hostedCluster.clusterID", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.configuration": { + FieldPath: "spec.hostedCluster.configuration", + WriteMode: ServiceSet, + }, + "spec.hostedCluster.controlPlaneRelease": { + FieldPath: "spec.hostedCluster.controlPlaneRelease", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.controllerAvailabilityPolicy": { + FieldPath: "spec.hostedCluster.controllerAvailabilityPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.dns": { + FieldPath: "spec.hostedCluster.dns", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.etcd": { + FieldPath: "spec.hostedCluster.etcd", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.fips": { + FieldPath: "spec.hostedCluster.fips", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.imageContentSources": { + FieldPath: "spec.hostedCluster.imageContentSources", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.infraID": { + FieldPath: "spec.hostedCluster.infraID", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.infrastructureAvailabilityPolicy": { + FieldPath: "spec.hostedCluster.infrastructureAvailabilityPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.issuerURL": { + FieldPath: "spec.hostedCluster.issuerURL", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.kubeAPIServerDNSName": { + FieldPath: "spec.hostedCluster.kubeAPIServerDNSName", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.labels": { + FieldPath: "spec.hostedCluster.labels", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.networking": { + FieldPath: "spec.hostedCluster.networking", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.nodeSelector": { + FieldPath: "spec.hostedCluster.nodeSelector", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.olmCatalogPlacement": { + FieldPath: "spec.hostedCluster.olmCatalogPlacement", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.operatorConfiguration": { + FieldPath: "spec.hostedCluster.operatorConfiguration", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.pausedUntil": { + FieldPath: "spec.hostedCluster.pausedUntil", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.platform": { + FieldPath: "spec.hostedCluster.platform", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.pullSecret": { + FieldPath: "spec.hostedCluster.pullSecret", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.release": { + FieldPath: "spec.hostedCluster.release", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.secretEncryption": { + FieldPath: "spec.hostedCluster.secretEncryption", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.serviceAccountSigningKey": { + FieldPath: "spec.hostedCluster.serviceAccountSigningKey", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.services": { + FieldPath: "spec.hostedCluster.services", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.sshKey": { + FieldPath: "spec.hostedCluster.sshKey", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.tolerations": { + FieldPath: "spec.hostedCluster.tolerations", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.hostedCluster.updateService": { + FieldPath: "spec.hostedCluster.updateService", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.internalId": { + FieldPath: "spec.internalId", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.internalPoolId": { + FieldPath: "spec.internalPoolId", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.labels": { + FieldPath: "spec.labels", + WriteMode: Mutable, + }, + "spec.nodePool.arch": { + FieldPath: "spec.nodePool.arch", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.autoScaling": { + FieldPath: "spec.nodePool.autoScaling", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.clusterName": { + FieldPath: "spec.nodePool.clusterName", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.config": { + FieldPath: "spec.nodePool.config", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.management": { + FieldPath: "spec.nodePool.management", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.nodeDrainTimeout": { + FieldPath: "spec.nodePool.nodeDrainTimeout", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.nodeLabels": { + FieldPath: "spec.nodePool.nodeLabels", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.nodeVolumeDetachTimeout": { + FieldPath: "spec.nodePool.nodeVolumeDetachTimeout", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.pausedUntil": { + FieldPath: "spec.nodePool.pausedUntil", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.platform": { + FieldPath: "spec.nodePool.platform", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.release": { + FieldPath: "spec.nodePool.release", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.replicas": { + FieldPath: "spec.nodePool.replicas", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.taints": { + FieldPath: "spec.nodePool.taints", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.nodePool.tuningConfig": { + FieldPath: "spec.nodePool.tuningConfig", + WriteMode: ServiceSet, + Hidden: true, + }, + "spec.properties": { + FieldPath: "spec.properties", + WriteMode: Mutable, + }, + "spec.tags": { + FieldPath: "spec.tags", + WriteMode: Mutable, + FeatureGate: "HyperFleetAutoScaling", + }, +} diff --git a/platform-api/internal/codegen/registry/field_metadata.json b/platform-api/internal/codegen/registry/field_metadata.json new file mode 100644 index 00000000..5033f2bc --- /dev/null +++ b/platform-api/internal/codegen/registry/field_metadata.json @@ -0,0 +1,284 @@ +[ + { + "fieldPath": "spec.accountId", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.autoRepair", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.creatorARN", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.deleteProtection", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.displayName", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.expirationTimestamp", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.hostedCluster.additionalTrustBundle", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.auditWebhook", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.autoNode", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.autoscaling", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.capabilities", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.channel", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.clusterID", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.configuration", + "writeMode": "service-set" + }, + { + "fieldPath": "spec.hostedCluster.controlPlaneRelease", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.controllerAvailabilityPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.dns", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.etcd", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.fips", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.imageContentSources", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.infraID", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.infrastructureAvailabilityPolicy", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.issuerURL", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.kubeAPIServerDNSName", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.labels", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.networking", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.nodeSelector", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.olmCatalogPlacement", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.operatorConfiguration", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.pausedUntil", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.platform", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.pullSecret", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.release", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.secretEncryption", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.serviceAccountSigningKey", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.services", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.sshKey", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.tolerations", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.hostedCluster.updateService", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.internalId", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.internalPoolId", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.labels", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.nodePool.arch", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.autoScaling", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.clusterName", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.config", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.management", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.nodeDrainTimeout", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.nodeLabels", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.nodeVolumeDetachTimeout", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.pausedUntil", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.platform", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.release", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.replicas", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.taints", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.nodePool.tuningConfig", + "writeMode": "service-set", + "hidden": true + }, + { + "fieldPath": "spec.properties", + "writeMode": "mutable" + }, + { + "fieldPath": "spec.tags", + "writeMode": "mutable", + "featureGate": "HyperFleetAutoScaling" + } +] \ No newline at end of file diff --git a/platform-api/openapi/openapi.yaml b/platform-api/openapi/openapi.yaml index 8fdabf7e..de72c90b 100644 --- a/platform-api/openapi/openapi.yaml +++ b/platform-api/openapi/openapi.yaml @@ -6,11 +6,9 @@ info: license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0 - servers: - url: /api/v0 description: API v0 - paths: /info: get: @@ -40,7 +38,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /management_clusters: post: summary: Create a new management cluster @@ -87,7 +84,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - get: summary: List all management clusters description: | @@ -117,7 +113,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /management_clusters/{id}: get: summary: Get a management cluster by ID @@ -159,7 +154,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - # Cluster Management Endpoints /clusters: get: @@ -197,7 +191,6 @@ paths: $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" - post: summary: Create cluster description: | @@ -238,7 +231,6 @@ paths: $ref: "#/components/responses/Conflict" "500": $ref: "#/components/responses/InternalError" - /clusters/{id}: parameters: - name: id @@ -247,7 +239,6 @@ paths: schema: type: string description: Cluster ID - get: summary: Get cluster details description: Retrieve cluster details (user must own the cluster) @@ -271,7 +262,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - patch: summary: Update cluster description: Update cluster spec (user must own the cluster) @@ -301,7 +291,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - put: summary: Update cluster description: Update cluster spec (user must own the cluster). Same behavior as PATCH. @@ -331,7 +320,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - delete: summary: Delete cluster description: Delete cluster (user must own the cluster) @@ -360,7 +348,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - /clusters/{id}/statuses: parameters: - name: id @@ -369,7 +356,6 @@ paths: schema: type: string description: Cluster ID - get: summary: Get cluster statuses description: Retrieve cluster status and controller statuses (user must own the cluster) @@ -393,7 +379,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - # NodePool Management Endpoints /nodepools: get: @@ -436,7 +421,6 @@ paths: $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" - post: summary: Create nodepool description: Create a new nodepool for the authenticated user @@ -466,7 +450,6 @@ paths: $ref: "#/components/responses/Conflict" "500": $ref: "#/components/responses/InternalError" - /nodepools/{id}: parameters: - name: id @@ -475,7 +458,6 @@ paths: schema: type: string description: NodePool ID - get: summary: Get nodepool details description: Retrieve nodepool details (user must own the cluster) @@ -499,7 +481,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - put: summary: Update nodepool description: Update nodepool (user must own the cluster) @@ -529,7 +510,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - delete: summary: Delete nodepool description: Delete nodepool (user must own the cluster) @@ -558,7 +538,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - /nodepools/{id}/status: parameters: - name: id @@ -567,7 +546,6 @@ paths: schema: type: string description: NodePool ID - get: summary: Get nodepool status description: Retrieve nodepool status (user must own the cluster) @@ -591,7 +569,6 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" - /live: get: summary: Liveness probe @@ -606,7 +583,6 @@ paths: application/json: schema: $ref: "#/components/schemas/HealthStatus" - /ready: get: summary: Readiness probe @@ -627,7 +603,6 @@ paths: application/json: schema: $ref: "#/components/schemas/HealthStatus" - # Authorization - Account Management /accounts: post: @@ -703,7 +678,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /accounts/{id}: get: summary: Get an account @@ -776,7 +750,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - # Authorization - Check /authz/check: post: @@ -818,7 +791,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - # Authorization - Policy Management /authz/policies: post: @@ -886,7 +858,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /authz/policies/{id}: get: summary: Get a policy @@ -1022,7 +993,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - # Authorization - Group Management /authz/groups: post: @@ -1087,7 +1057,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /authz/groups/{id}: get: summary: Get a group @@ -1165,7 +1134,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /authz/groups/{id}/members: get: summary: List group members @@ -1257,7 +1225,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - # Authorization - Attachment Management /authz/attachments: post: @@ -1350,7 +1317,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /authz/attachments/{id}: delete: summary: Delete an attachment @@ -1389,7 +1355,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - # Authorization - Admin Management /authz/admins: post: @@ -1456,7 +1421,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /authz/admins/{arn}: delete: summary: Remove an admin @@ -1486,7 +1450,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - # ZOA Trusted Actions /trusted-actions: get: @@ -1508,7 +1471,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /trusted-actions/{action}: get: summary: Describe a trusted action @@ -1536,7 +1498,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /trusted-actions/{action}/run: post: summary: Execute a trusted action @@ -1596,7 +1557,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /trusted-actions/runs: get: summary: List executions @@ -1681,7 +1641,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /trusted-actions/runs/{id}: get: summary: Get execution details @@ -1723,7 +1682,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /trusted-actions/audit: get: summary: List audit log @@ -1785,7 +1743,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - components: schemas: ManagementClusterRequest: @@ -1805,7 +1762,6 @@ components: accountId: type: string description: AWS account ID that owns the management cluster - ManagementCluster: type: object description: A management cluster registered on hyperfleet-db @@ -1821,7 +1777,6 @@ components: accountId: type: string description: AWS account ID that owns the management cluster - ManagementClusterList: type: object description: List of management clusters @@ -1841,7 +1796,6 @@ components: type: array items: $ref: "#/components/schemas/ManagementCluster" - Error: type: object description: Error response @@ -1860,7 +1814,6 @@ components: reason: type: string description: Human-readable error message - HealthStatus: type: object description: Health check response @@ -1869,7 +1822,6 @@ components: type: string enum: [ok, degraded, unavailable] description: Health status - # Authorization Schemas EnableAccountRequest: type: object @@ -1884,7 +1836,6 @@ components: type: boolean description: If true, account bypasses all authorization checks default: false - Account: type: object description: An enabled account @@ -1910,7 +1861,6 @@ components: createdBy: type: string description: ARN of who enabled the account - AccountList: type: object description: List of accounts @@ -1929,7 +1879,6 @@ components: total: type: integer description: Total number of accounts - CheckAuthorizationRequest: type: object description: Request body for checking authorization @@ -1956,7 +1905,6 @@ components: description: Tags on the resource additionalProperties: type: string - CheckAuthorizationResponse: type: object description: Authorization decision response @@ -1974,7 +1922,6 @@ components: reason: type: string description: Reason for the decision - CreatePolicyRequest: type: object description: Request body for creating a policy @@ -1991,7 +1938,6 @@ components: policy: type: string description: Native Cedar policy text - UpdatePolicyRequest: type: object description: Request body for updating a policy @@ -2005,7 +1951,6 @@ components: policy: type: string description: Native Cedar policy text - Policy: type: object description: A Cedar policy @@ -2031,7 +1976,6 @@ components: createdAt: type: string description: Creation timestamp - PolicyList: type: object description: List of policies @@ -2049,7 +1993,6 @@ components: type: array items: $ref: "#/components/schemas/Policy" - CreateGroupRequest: type: object description: Request body for creating a group @@ -2062,7 +2005,6 @@ components: description: type: string description: Optional group description - Group: type: object description: A group of users @@ -2088,7 +2030,6 @@ components: createdAt: type: string description: Creation timestamp - GroupList: type: object description: List of groups @@ -2106,7 +2047,6 @@ components: type: array items: $ref: "#/components/schemas/Group" - MemberList: type: object description: List of group member ARNs @@ -2125,7 +2065,6 @@ components: description: Member ARNs items: type: string - UpdateGroupMembersRequest: type: object description: Request body for updating group members @@ -2140,7 +2079,6 @@ components: description: ARNs to remove from the group items: type: string - CreateAttachmentRequest: type: object description: Request body for creating an attachment @@ -2160,7 +2098,6 @@ components: targetId: type: string description: Target ID (ARN for user, groupId for group) - Attachment: type: object description: A policy attachment to a user or group @@ -2193,7 +2130,6 @@ components: createdAt: type: string description: Creation timestamp - AttachmentList: type: object description: List of attachments @@ -2211,7 +2147,6 @@ components: type: array items: $ref: "#/components/schemas/Attachment" - AddAdminRequest: type: object description: Request body for adding an admin @@ -2221,7 +2156,6 @@ components: principalArn: type: string description: ARN of the principal to make admin - Admin: type: object description: An admin for the account @@ -2235,7 +2169,6 @@ components: principalArn: type: string description: Admin's AWS ARN - AdminList: type: object description: List of admin ARNs @@ -2254,45 +2187,68 @@ components: description: Admin principal ARNs items: type: string - # Cluster Schemas ClusterSpec: - type: object - description: | - Cluster specification following the hyperfleet-operator v1alpha1.ClusterSpec - type. Contains a `creatorARN` and a nested `hostedCluster` field that follows - the HyperShift v1beta1 HostedClusterSpec schema. + description: |- + ClusterSpec defines the desired state of a ROSA HCP cluster. + metadata.Name is the human-readable cluster name; metadata.Namespace is the cluster UUID. + The owning AWS account is stored as the label hyperfleet.io/account-id. properties: - creatorARN: + deleteProtection: + description: DeleteProtection prevents accidental deletion when enabled. + type: boolean + displayName: + description: DisplayName is a human-readable name for the cluster. type: string - description: ARN of the user who created this cluster (auto-populated by the API) + expirationTimestamp: + description: ExpirationTimestamp marks when this cluster should be automatically deleted. + type: object hostedCluster: + $ref: '#/components/schemas/HostedClusterSpecPassthrough' + description: |- + === HyperShift Passthrough === + HostedCluster is the full HyperShift HostedClusterSpec. The customer provides + the fields they care about; the operator overrides platform-managed fields + (InfraID, DNS, PullSecret, Services, etc.) at render time. + properties: + additionalProperties: + type: string + description: Properties are arbitrary key-value pairs for customer metadata. type: object - description: | - HyperShift v1beta1 HostedClusterSpec. Key fields include: - - platform: Cloud provider configuration (type, aws) - - networking: Cluster/service/machine network CIDRs - - release: OpenShift release image - - issuerURL: OIDC issuer URL (auto-populated) - additionalProperties: true - - NodePoolSpec: + tags: + additionalProperties: + type: string + description: Tags are customer-defined labels for organizational purposes. + type: object + required: + - hostedCluster type: object - description: | - NodePool specification following the hyperfleet-operator v1alpha1.NodePoolSpec - type. Contains a nested `nodePool` field that follows the HyperShift v1beta1 - NodePoolSpec schema. + NodePoolSpec: + description: |- + NodePoolSpec defines the desired state of a NodePool. + The parent Cluster is identified by the shared metadata.Namespace (cluster UUID). properties: - nodePool: + autoRepair: + description: AutoRepair enables automatic repair of unhealthy nodes. + type: boolean + displayName: + description: DisplayName is a human-readable name for the node pool. + type: string + labels: + additionalProperties: + type: string + description: Labels to apply to nodes in this pool. type: object - description: | - HyperShift v1beta1 NodePoolSpec. Key fields include: - - platform: Cloud provider configuration (type, aws with instanceType, rootVolume) - - replicas: Number of worker nodes (default: 2) - - release: OpenShift release image - - management: Upgrade and repair configuration + nodePool: additionalProperties: true - + description: |- + NodePool is the full HyperShift NodePoolSpec. The customer provides replicas, + platform, release, etc. The operator overrides ClusterName and adds system + resource tags at render time. + type: object + required: + - nodePool + type: object Cluster: type: object description: A user cluster resource @@ -2342,7 +2298,6 @@ components: type: string format: date-time description: Last update timestamp - ClusterCreateRequest: type: object description: | @@ -2368,7 +2323,6 @@ components: description: Target project ID spec: $ref: "#/components/schemas/ClusterSpec" - ClusterUpdateRequest: type: object description: Request body for updating a cluster @@ -2377,7 +2331,6 @@ components: properties: spec: $ref: "#/components/schemas/ClusterSpec" - ClusterStatusInfo: type: object description: Aggregated status for clusters @@ -2412,7 +2365,6 @@ components: type: string format: date-time description: When status was last calculated - APIEndpoint: type: object description: Control plane API endpoint @@ -2422,7 +2374,6 @@ components: port: type: integer format: int32 - PlacementReference: type: object description: Management cluster assignment @@ -2433,7 +2384,6 @@ components: managementCluster: type: string description: Management cluster name - Condition: type: object description: Status condition @@ -2455,7 +2405,6 @@ components: message: type: string description: Human-readable message - ClusterControllerStatus: type: object description: Controller-specific status for a cluster @@ -2487,7 +2436,6 @@ components: type: string format: date-time description: When this controller last updated - ClusterStatusResponse: type: object description: Response for cluster status endpoint @@ -2502,7 +2450,6 @@ components: description: Individual controller status reports items: $ref: "#/components/schemas/ClusterControllerStatus" - ClusterList: type: object description: Paginated list of clusters @@ -2525,7 +2472,6 @@ components: offset: type: integer description: Number of items skipped - # NodePool Schemas NodePool: type: object @@ -2572,7 +2518,6 @@ components: type: string format: date-time description: Last update timestamp - NodePoolCreateRequest: type: object description: Request body for creating a nodepool @@ -2592,7 +2537,6 @@ components: description: DNS-compatible nodepool name spec: $ref: "#/components/schemas/NodePoolSpec" - NodePoolUpdateRequest: type: object description: Request body for updating a nodepool @@ -2601,7 +2545,6 @@ components: properties: spec: $ref: "#/components/schemas/NodePoolSpec" - NodePoolStatusInfo: type: object description: Aggregated status for nodepools @@ -2629,7 +2572,6 @@ components: type: string format: date-time description: When status was last calculated - NodePoolControllerStatus: type: object description: Controller-specific status for a nodepool @@ -2657,7 +2599,6 @@ components: type: string format: date-time description: When this controller last updated - NodePoolStatusResponse: type: object description: Response for nodepool status endpoint @@ -2672,7 +2613,6 @@ components: description: Individual controller status reports items: $ref: "#/components/schemas/NodePoolControllerStatus" - NodePoolList: type: object description: Paginated list of nodepools @@ -2695,7 +2635,6 @@ components: offset: type: integer description: Number of items skipped - # ZOA Trusted Action Schemas TrustedActionCatalog: type: object @@ -2710,7 +2649,6 @@ components: $ref: "#/components/schemas/TrustedActionListItem" total: type: integer - TrustedActionListItem: type: object description: Summary of a trusted action @@ -2723,7 +2661,6 @@ components: type: string description: type: string - TrustedActionDescribe: type: object description: Detailed trusted action metadata @@ -2755,7 +2692,6 @@ components: items: type: string example: [target_cluster, jira] - TrustedActionParam: type: object description: A parameter accepted by a trusted action @@ -2768,7 +2704,6 @@ components: type: string description: type: string - TrustedActionCreateRequest: type: object description: Request body for executing a trusted action @@ -2796,7 +2731,6 @@ components: type: boolean description: Execute read-only variant of the action default: false - Execution: type: object description: A trusted action execution record @@ -2857,7 +2791,6 @@ components: type: integer duration_seconds: type: integer - ExecutionResponse: type: object description: Full execution response with optional output and logs @@ -2870,7 +2803,6 @@ components: logs: type: string description: Execution logs from S3 (included when ?include=logs) - ExecutionList: type: object description: Paginated list of executions @@ -2890,7 +2822,6 @@ components: type: integer has_more: type: boolean - AuditEntry: type: object description: An audit log entry for a trusted action API call @@ -2923,7 +2854,6 @@ components: type: integer timestamp: type: string - AuditList: type: object description: List of audit log entries @@ -2941,7 +2871,121 @@ components: $ref: "#/components/schemas/AuditEntry" total: type: integer - + MachineConfigSpec: + description: |- + MachineConfigSpec specifies machine-level configuration. + This controls kernel parameters, systemd units, and file writes. + Most fields are platform-managed for security and stability. + properties: + allowedKernelArguments: + description: |- + allowedKernelArguments specifies kernel parameters customers can request. + This is a WHITELIST approach - customers can only request known-safe parameters. + Platform validates against an allowlist and applies approved parameters. + Tech Preview feature requiring explicit enablement. + items: + type: string + type: array + fips: + description: |- + fips enables FIPS mode on nodes. + Immutable - must be set at cluster creation, cannot be changed. + type: boolean + type: object + ClusterConfiguration: + description: |- + ClusterConfiguration specifies configuration for individual OCP components in the cluster. + This is a HyperFleet-owned mirror of hypershiftv1beta1.ClusterConfiguration that allows + us to add granular markers to nested fields like kubelet config. + properties: + kubelet: + $ref: '#/components/schemas/KubeletConfig' + description: |- + kubelet contains the configuration for kubelet on nodes. + This is where we can add granular control over kubelet fields. + machineConfig: + $ref: '#/components/schemas/MachineConfigSpec' + description: |- + machineConfig contains the configuration for machine-level settings (kernel params, systemd, files). + Granular markers allow safe subset exposure while hiding dangerous operations. + type: object + KubeletConfig: + description: |- + KubeletConfig specifies kubelet configuration. + This is a HyperFleet-owned type that mirrors hypershiftv1beta1.KubeletConfig + with granular markers for customer control. + properties: + containerLogMaxFiles: + description: containerLogMaxFiles is the maximum number of container log files. + format: int32 + type: integer + containerLogMaxSize: + description: containerLogMaxSize is the maximum size of container log file before it is rotated. + type: string + imageGCHighThresholdPercent: + description: imageGCHighThresholdPercent is the disk usage percent triggering image GC. + format: int32 + type: integer + imageGCLowThresholdPercent: + description: imageGCLowThresholdPercent is the disk usage percent to gc to. + format: int32 + type: integer + imageMinimumGCAge: + description: imageMinimumGCAge is the minimum age for an unused image before it is garbage collected. + type: object + kubeReserved: + additionalProperties: + type: string + description: kubeReserved specifies resources reserved for Kubernetes system components. + type: object + maxPods: + description: |- + maxPods is the maximum number of pods per node. + Customers can set this to optimize for high-density workloads. + format: int32 + type: integer + podPidsLimit: + description: |- + podPidsLimit is the maximum number of PIDs allowed per pod. + Customers can increase this for applications that spawn many processes. + format: int64 + type: integer + registryBurst: + description: registryBurst is the maximum size of bursty pulls, temporarily allows pulls to burst. + format: int32 + type: integer + registryPullQPS: + description: registryPullQPS is the limit of registry pulls per second. + format: int32 + type: integer + serializeImagePulls: + description: |- + serializeImagePulls when enabled, tells kubelet to pull images one at a time. + Tech preview feature for optimizing image pull performance. + type: boolean + streamingConnectionIdleTimeout: + description: streamingConnectionIdleTimeout is the maximum time a streaming connection can be idle. + type: object + systemReserved: + additionalProperties: + type: string + description: |- + systemReserved specifies resources reserved for system daemons. + Customers can set this on cluster creation but cannot change it later. + type: object + type: object + HostedClusterSpecPassthrough: + description: HostedClusterSpecPassthrough mirrors HostedClusterSpec from upstream HyperShift + properties: + autoNode: + description: autoNode specifies the configuration for automatic node provisioning and lifecycle management. + type: object + configuration: + $ref: '#/components/schemas/ClusterConfiguration' + description: configuration specifies configuration for individual OCP components in the + required: + - autoNode + type: object responses: BadRequest: description: Bad request diff --git a/platform-api/openapi/swagger-ui/index.html b/platform-api/openapi/swagger-ui/index.html new file mode 100644 index 00000000..55fe6ee4 --- /dev/null +++ b/platform-api/openapi/swagger-ui/index.html @@ -0,0 +1,30 @@ + + + + + Hyperfleet Platform API - Swagger UI + + + + +
+ + + + + diff --git a/platform-api/pkg/config/config.go b/platform-api/pkg/config/config.go index ce838972..f6c135f6 100644 --- a/platform-api/pkg/config/config.go +++ b/platform-api/pkg/config/config.go @@ -23,6 +23,7 @@ type DBConfig struct { type RegionalConfig struct { OIDCIssuerBaseURL string DefaultClusterExpiration time.Duration + FeatureSet string } type ZoaConfig struct { diff --git a/platform-api/pkg/handlers/cluster.go b/platform-api/pkg/handlers/cluster.go index 1eb90e9c..e853573d 100644 --- a/platform-api/pkg/handlers/cluster.go +++ b/platform-api/pkg/handlers/cluster.go @@ -12,9 +12,12 @@ import ( "github.com/gorilla/mux" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/conversion" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/types" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/validation" ) // ClusterHandler handles cluster-related HTTP requests @@ -22,15 +25,19 @@ type ClusterHandler struct { db *hyperfleetdb.Client oidcIssuerBaseURL string defaultClusterExpiration time.Duration + validator *validation.FieldValidator + featureSet featuregate.FeatureSet logger *slog.Logger } // NewClusterHandler creates a new cluster handler -func NewClusterHandler(db *hyperfleetdb.Client, oidcIssuerBaseURL string, defaultClusterExpiration time.Duration, logger *slog.Logger) *ClusterHandler { +func NewClusterHandler(db *hyperfleetdb.Client, oidcIssuerBaseURL string, defaultClusterExpiration time.Duration, validator *validation.FieldValidator, featureSet featuregate.FeatureSet, logger *slog.Logger) *ClusterHandler { return &ClusterHandler{ db: db, oidcIssuerBaseURL: oidcIssuerBaseURL, defaultClusterExpiration: defaultClusterExpiration, + validator: validator, + featureSet: featureSet, logger: logger, } } @@ -108,6 +115,11 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { return } + if errs := h.validator.ValidateClusterCreate(req.Spec, h.featureSet); errs != nil { + h.writeValidationError(w, "CLUSTERS-MGMT-VALIDATION-001", errs) + return + } + existing, err := h.db.ListClusters(ctx, accountID) if err != nil { h.logger.Error("failed to check cluster name uniqueness", "error", err, "account_id", accountID) @@ -122,10 +134,6 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { } } - if callerARN := middleware.GetCallerARN(ctx); callerARN != "" { - req.Spec.CreatorARN = callerARN - } - clusterID := uuid.New().String() h.logger.Info("creating cluster", "account_id", accountID, "cluster_name", req.Name, "cluster_id", clusterID) @@ -137,15 +145,16 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { return } + conversion.InjectClusterServiceSet(&cr.Spec, conversion.ClusterServiceSetFields{ + CreatorARN: middleware.GetCallerARN(ctx), + IssuerURL: h.oidcIssuerBaseURL + "/" + clusterID, + }) + if h.defaultClusterExpiration > 0 && cr.Spec.ExpirationTimestamp == nil { expiry := metav1.NewTime(time.Now().Add(h.defaultClusterExpiration)) cr.Spec.ExpirationTimestamp = &expiry } - if h.oidcIssuerBaseURL != "" { - cr.Spec.HostedCluster.IssuerURL = h.oidcIssuerBaseURL + "/" + clusterID - } - if err := h.db.CreateCluster(ctx, accountID, cr); err != nil { h.logger.Error("failed to create cluster", "error", err, "account_id", accountID) if hyperfleetdb.IsAlreadyExists(err) { @@ -214,8 +223,12 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { return } - existingIssuerURL := cr.Spec.HostedCluster.IssuerURL - existingExpiration := cr.Spec.ExpirationTimestamp + if errs := h.validator.ValidateClusterUpdate(req.Spec, &cr.Spec, h.featureSet); errs != nil { + h.writeValidationError(w, "CLUSTERS-MGMT-VALIDATION-002", errs) + return + } + + snapshot := cr.Spec if err := hyperfleetdb.ApplyPlatformUpdateToClusterCR(cr, &req); err != nil { h.logger.Error("failed to merge cluster spec", "error", err) @@ -223,9 +236,9 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { return } - cr.Spec.HostedCluster.IssuerURL = existingIssuerURL + conversion.PreserveClusterServiceSet(&cr.Spec, &snapshot) if cr.Spec.ExpirationTimestamp == nil { - cr.Spec.ExpirationTimestamp = existingExpiration + cr.Spec.ExpirationTimestamp = snapshot.ExpirationTimestamp } if err := h.db.UpdateCluster(ctx, cr); err != nil { @@ -305,3 +318,15 @@ func (h *ClusterHandler) writeError(w http.ResponseWriter, status int, code, rea } _ = json.NewEncoder(w).Encode(resp) } + +func (h *ClusterHandler) writeValidationError(w http.ResponseWriter, code string, errs validation.ValidationErrors) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + resp := map[string]any{ + "kind": "Error", + "code": code, + "reason": "Request validation failed", + "details": errs, + } + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/platform-api/pkg/handlers/nodepool.go b/platform-api/pkg/handlers/nodepool.go index d2c7bf14..44673cb2 100644 --- a/platform-api/pkg/handlers/nodepool.go +++ b/platform-api/pkg/handlers/nodepool.go @@ -7,20 +7,27 @@ import ( "strconv" "github.com/gorilla/mux" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/conversion" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/types" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/validation" ) type NodePoolHandler struct { - db *hyperfleetdb.Client - logger *slog.Logger + db *hyperfleetdb.Client + validator *validation.FieldValidator + featureSet featuregate.FeatureSet + logger *slog.Logger } -func NewNodePoolHandler(db *hyperfleetdb.Client, logger *slog.Logger) *NodePoolHandler { +func NewNodePoolHandler(db *hyperfleetdb.Client, validator *validation.FieldValidator, featureSet featuregate.FeatureSet, logger *slog.Logger) *NodePoolHandler { return &NodePoolHandler{ - db: db, - logger: logger, + db: db, + validator: validator, + featureSet: featureSet, + logger: logger, } } @@ -95,6 +102,11 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { return } + if errs := h.validator.ValidateNodePoolCreate(req.Spec, h.featureSet); errs != nil { + h.writeValidationError(w, "NODEPOOLS-MGMT-VALIDATION-001", errs) + return + } + if _, err := h.db.GetCluster(ctx, accountID, req.ClusterID); err != nil { if hyperfleetdb.IsNotFound(err) { h.writeError(w, http.StatusNotFound, "NODEPOOLS-MGMT-CREATE-004", "Referenced cluster not found") @@ -179,12 +191,21 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { return } + if errs := h.validator.ValidateNodePoolUpdate(req.Spec, &cr.Spec, h.featureSet); errs != nil { + h.writeValidationError(w, "NODEPOOLS-MGMT-VALIDATION-002", errs) + return + } + + snapshot := cr.Spec + if err := hyperfleetdb.ApplyPlatformUpdateToNodePoolCR(cr, &req); err != nil { h.logger.Error("failed to merge nodepool spec", "error", err) h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-UPDATE-002", "Invalid nodepool spec") return } + conversion.PreserveNodePoolServiceSet(&cr.Spec, &snapshot) + if err := h.db.UpdateNodePool(ctx, cr); err != nil { h.logger.Error("failed to update nodepool", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-UPDATE-004", "Failed to update nodepool") @@ -259,3 +280,15 @@ func (h *NodePoolHandler) writeError(w http.ResponseWriter, status int, code, re } _ = json.NewEncoder(w).Encode(resp) } + +func (h *NodePoolHandler) writeValidationError(w http.ResponseWriter, code string, errs validation.ValidationErrors) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + resp := map[string]any{ + "kind": "Error", + "code": code, + "reason": "Request validation failed", + "details": errs, + } + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/platform-api/pkg/server/server.go b/platform-api/pkg/server/server.go index e7ad3681..07c216c9 100644 --- a/platform-api/pkg/server/server.go +++ b/platform-api/pkg/server/server.go @@ -10,12 +10,14 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gorilla/mux" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/authz" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/authz/client" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/config" apphandlers "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/handlers" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/validation" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/zoa" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -36,12 +38,19 @@ type Server struct { func New(cfg *config.Config, dbClient *hyperfleetdb.Client, logger *slog.Logger) (*Server, error) { ctx := context.Background() + // Create field validator + fieldValidator := validation.NewFieldValidator() + fs := featuregate.FeatureSet(cfg.Regional.FeatureSet) + if fs == "" { + fs = featuregate.Default + } + // Create handlers healthHandler := apphandlers.NewHealthHandler() infoHandler := apphandlers.NewInfoHandler() mgmtClusterHandler := apphandlers.NewManagementClusterHandler(dbClient, logger) - clusterHandler := apphandlers.NewClusterHandler(dbClient, cfg.Regional.OIDCIssuerBaseURL, cfg.Regional.DefaultClusterExpiration, logger) - nodePoolHandler := apphandlers.NewNodePoolHandler(dbClient, logger) + clusterHandler := apphandlers.NewClusterHandler(dbClient, cfg.Regional.OIDCIssuerBaseURL, cfg.Regional.DefaultClusterExpiration, fieldValidator, fs, logger) + nodePoolHandler := apphandlers.NewNodePoolHandler(dbClient, fieldValidator, fs, logger) // Create legacy authorization middleware (for non-authz routes) authMiddleware := middleware.NewAuthorization(cfg.AllowedAccounts, logger) diff --git a/platform-api/pkg/validation/field_validator.go b/platform-api/pkg/validation/field_validator.go new file mode 100644 index 00000000..868e63cd --- /dev/null +++ b/platform-api/pkg/validation/field_validator.go @@ -0,0 +1,208 @@ +package validation + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + hyperfleetv1alpha1 "github.com/openshift-online/rosa-hyperfleet-api/hyperfleet-operator/api/v1alpha1" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/registry" +) + +type Operation string + +const ( + OperationCreate Operation = "create" + OperationUpdate Operation = "update" +) + +type ValidationError struct { + Field string `json:"field"` + Reason string `json:"reason"` +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("field %s: %s", e.Field, e.Reason) +} + +type ValidationErrors []*ValidationError + +func (e ValidationErrors) Error() string { + if len(e) == 0 { + return "no validation errors" + } + var sb strings.Builder + sb.WriteString("validation failed:\n") + for _, err := range e { + sb.WriteString(" ") + sb.WriteString(err.Error()) + sb.WriteString("\n") + } + return sb.String() +} + +type FieldValidator struct { + registry map[string]registry.FieldMeta +} + +func NewFieldValidator() *FieldValidator { + return &FieldValidator{ + registry: registry.FieldRegistry, + } +} + +func (v *FieldValidator) ValidateClusterCreate(spec *hyperfleetv1alpha1.ClusterSpec, fs featuregate.FeatureSet) ValidationErrors { + if spec == nil { + return nil + } + fields := flattenToFieldPaths("spec", spec) + return v.validate(fields, nil, OperationCreate, fs) +} + +func (v *FieldValidator) ValidateClusterUpdate(newSpec, existingSpec *hyperfleetv1alpha1.ClusterSpec, fs featuregate.FeatureSet) ValidationErrors { + if newSpec == nil { + return nil + } + newFields := flattenToFieldPaths("spec", newSpec) + var existingFields map[string]interface{} + if existingSpec != nil { + existingFields = flattenToFieldPaths("spec", existingSpec) + } + return v.validate(newFields, existingFields, OperationUpdate, fs) +} + +func (v *FieldValidator) ValidateNodePoolCreate(spec *hyperfleetv1alpha1.NodePoolSpec, fs featuregate.FeatureSet) ValidationErrors { + if spec == nil { + return nil + } + fields := flattenToFieldPaths("spec", spec) + return v.validate(fields, nil, OperationCreate, fs) +} + +func (v *FieldValidator) ValidateNodePoolUpdate(newSpec, existingSpec *hyperfleetv1alpha1.NodePoolSpec, fs featuregate.FeatureSet) ValidationErrors { + if newSpec == nil { + return nil + } + newFields := flattenToFieldPaths("spec", newSpec) + var existingFields map[string]interface{} + if existingSpec != nil { + existingFields = flattenToFieldPaths("spec", existingSpec) + } + return v.validate(newFields, existingFields, OperationUpdate, fs) +} + +func (v *FieldValidator) validate(fields, existingFields map[string]interface{}, op Operation, fs featuregate.FeatureSet) ValidationErrors { + var errs ValidationErrors + + for fieldPath := range fields { + meta, exists := v.registry[fieldPath] + if !exists { + continue + } + + if meta.FeatureGate != "" { + if !featuregate.IsGateEnabled(meta.FeatureGate, fs) { + errs = append(errs, &ValidationError{ + Field: fieldPath, + Reason: fmt.Sprintf("requires feature gate %s which is not enabled in %s feature set", meta.FeatureGate, fs), + }) + continue + } + } + + if err := v.validateWriteMode(fieldPath, meta, op, fields, existingFields, fs); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errs + } + return nil +} + +func (v *FieldValidator) validateWriteMode(fieldPath string, meta registry.FieldMeta, op Operation, fields, existingFields map[string]interface{}, fs featuregate.FeatureSet) *ValidationError { + effectiveMode := meta.WriteMode + + if len(meta.FeatureGateAwareWriteModes) > 0 { + matched := false + for _, override := range meta.FeatureGateAwareWriteModes { + if override.FeatureGate != "" && featuregate.IsGateEnabled(override.FeatureGate, fs) { + effectiveMode = override.WriteMode + matched = true + break + } + } + if !matched { + for _, override := range meta.FeatureGateAwareWriteModes { + if override.FeatureGate == "" { + effectiveMode = override.WriteMode + break + } + } + } + } + + switch effectiveMode { + case registry.ServiceSet: + return &ValidationError{ + Field: fieldPath, + Reason: "field is platform-managed (service-set) and cannot be set by customers", + } + case registry.Immutable: + if op == OperationUpdate && existingFields != nil { + oldVal, existsInOld := existingFields[fieldPath] + if existsInOld { + newVal := fields[fieldPath] + if !reflect.DeepEqual(oldVal, newVal) { + return &ValidationError{ + Field: fieldPath, + Reason: "field is immutable and cannot be changed after creation", + } + } + } + } + return nil + case registry.Mutable: + return nil + default: + return nil + } +} + +// flattenToFieldPaths converts a struct to a map of dot-separated field paths +// via JSON round-trip. The prefix is prepended to all paths (e.g., "spec"). +func flattenToFieldPaths(prefix string, v interface{}) map[string]interface{} { //nolint:unparam + data, err := json.Marshal(v) + if err != nil { + return nil + } + + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return nil + } + + result := make(map[string]interface{}) + flattenMap(prefix, m, result) + return result +} + +func flattenMap(prefix string, m map[string]interface{}, result map[string]interface{}) { + for key, val := range m { + var path string + if prefix == "" { + path = key + } else { + path = prefix + "." + key + } + + result[path] = val + + if nested, ok := val.(map[string]interface{}); ok { + flattenMap(path, nested, result) + } + } +} diff --git a/platform-api/pkg/validation/field_validator_test.go b/platform-api/pkg/validation/field_validator_test.go new file mode 100644 index 00000000..7799ea65 --- /dev/null +++ b/platform-api/pkg/validation/field_validator_test.go @@ -0,0 +1,221 @@ +package validation + +import ( + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/registry" +) + +func newTestValidator(entries map[string]registry.FieldMeta) *FieldValidator { + return &FieldValidator{registry: entries} +} + +func TestValidate_MutableFieldAllowed(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.displayName": {FieldPath: "spec.displayName", WriteMode: registry.Mutable}, + }) + + fields := map[string]interface{}{"spec.displayName": "my-cluster"} + + for _, op := range []Operation{OperationCreate, OperationUpdate} { + errs := v.validate(fields, nil, op, featuregate.Default) + if errs != nil { + t.Errorf("mutable field on %s should be allowed, got: %v", op, errs) + } + } +} + +func TestValidate_ServiceSetFieldBlocked(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.accountId": {FieldPath: "spec.accountId", WriteMode: registry.ServiceSet}, + }) + + fields := map[string]interface{}{"spec.accountId": "123"} + + for _, op := range []Operation{OperationCreate, OperationUpdate} { + errs := v.validate(fields, nil, op, featuregate.Default) + if errs == nil { + t.Errorf("service-set field on %s should be blocked", op) + continue + } + if len(errs) != 1 || errs[0].Field != "spec.accountId" { + t.Errorf("unexpected error: %v", errs) + } + } +} + +func TestValidate_ImmutableFieldOnCreate(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.name": {FieldPath: "spec.name", WriteMode: registry.Immutable}, + }) + + fields := map[string]interface{}{"spec.name": "my-cluster"} + errs := v.validate(fields, nil, OperationCreate, featuregate.Default) + if errs != nil { + t.Errorf("immutable field on create should be allowed, got: %v", errs) + } +} + +func TestValidate_ImmutableFieldChangedOnUpdate(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.name": {FieldPath: "spec.name", WriteMode: registry.Immutable}, + }) + + fields := map[string]interface{}{"spec.name": "new-name"} + existing := map[string]interface{}{"spec.name": "old-name"} + + errs := v.validate(fields, existing, OperationUpdate, featuregate.Default) + if errs == nil { + t.Error("immutable field change on update should be blocked") + return + } + if len(errs) != 1 || errs[0].Field != "spec.name" { + t.Errorf("unexpected error: %v", errs) + } +} + +func TestValidate_ImmutableFieldFirstSetOnUpdate(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.name": {FieldPath: "spec.name", WriteMode: registry.Immutable}, + }) + + fields := map[string]interface{}{"spec.name": "new-name"} + existing := map[string]interface{}{} + + errs := v.validate(fields, existing, OperationUpdate, featuregate.Default) + if errs != nil { + t.Errorf("immutable field first-set on update should be allowed, got: %v", errs) + } +} + +func TestValidate_FeatureGateBlocked(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.tags": {FieldPath: "spec.tags", WriteMode: registry.Mutable, FeatureGate: "HyperFleetAutoScaling"}, + }) + + fields := map[string]interface{}{"spec.tags": map[string]string{"env": "prod"}} + errs := v.validate(fields, nil, OperationCreate, featuregate.Default) + if errs == nil { + t.Error("feature-gated field without gate should be blocked") + return + } + if len(errs) != 1 || errs[0].Field != "spec.tags" { + t.Errorf("unexpected error: %v", errs) + } +} + +func TestValidate_FeatureGateAllowed(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.tags": {FieldPath: "spec.tags", WriteMode: registry.Mutable, FeatureGate: "HyperFleetAutoScaling"}, + }) + + fields := map[string]interface{}{"spec.tags": map[string]string{"env": "prod"}} + errs := v.validate(fields, nil, OperationCreate, featuregate.TechPreviewNoUpgrade) + if errs != nil { + t.Errorf("feature-gated field with gate enabled should be allowed, got: %v", errs) + } +} + +func TestValidate_UnknownFieldAllowed(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{}) + + fields := map[string]interface{}{"spec.unknownField": "value"} + errs := v.validate(fields, nil, OperationCreate, featuregate.Default) + if errs != nil { + t.Errorf("unknown field should be allowed, got: %v", errs) + } +} + +func TestValidate_EmptyFields(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.accountId": {FieldPath: "spec.accountId", WriteMode: registry.ServiceSet}, + }) + + errs := v.validate(map[string]interface{}{}, nil, OperationCreate, featuregate.Default) + if errs != nil { + t.Errorf("empty fields should produce no errors, got: %v", errs) + } +} + +func TestFlattenToFieldPaths(t *testing.T) { + type inner struct { + Name string `json:"name"` + } + type outer struct { + Display string `json:"displayName"` + Nested inner `json:"nested"` + } + + result := flattenToFieldPaths("spec", &outer{ + Display: "test", + Nested: inner{Name: "foo"}, + }) + + if _, ok := result["spec.displayName"]; !ok { + t.Error("expected spec.displayName in flattened paths") + } + if _, ok := result["spec.nested"]; !ok { + t.Error("expected spec.nested in flattened paths") + } + if _, ok := result["spec.nested.name"]; !ok { + t.Error("expected spec.nested.name in flattened paths") + } +} + +func TestValidate_ImmutableUnchangedAllowed(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.name": {FieldPath: "spec.name", WriteMode: registry.Immutable}, + }) + + fields := map[string]interface{}{"spec.name": "same-value"} + existing := map[string]interface{}{"spec.name": "same-value"} + + errs := v.validate(fields, existing, OperationUpdate, featuregate.Default) + if errs != nil { + t.Errorf("unchanged immutable field should be allowed, got: %v", errs) + } +} + +func TestValidate_FeatureGateAwareWriteModes(t *testing.T) { + v := newTestValidator(map[string]registry.FieldMeta{ + "spec.releaseChannel": { + FieldPath: "spec.releaseChannel", + WriteMode: registry.Immutable, + FeatureGateAwareWriteModes: []registry.FeatureGateWriteMode{ + {FeatureGate: "", WriteMode: registry.Immutable}, + {FeatureGate: "HyperFleetAutoScaling", WriteMode: registry.Mutable}, + }, + }, + }) + + fields := map[string]interface{}{"spec.releaseChannel": "new-channel"} + existing := map[string]interface{}{"spec.releaseChannel": "old-channel"} + + // Default feature set: gate not enabled, falls back to immutable — change blocked + errs := v.validate(fields, existing, OperationUpdate, featuregate.Default) + if errs == nil { + t.Error("expected immutable error when gate not enabled") + return + } + if len(errs) != 1 || errs[0].Field != "spec.releaseChannel" { + t.Errorf("unexpected error: %v", errs) + } + + // TechPreview feature set: HyperFleetAutoScaling enabled, overrides to mutable — change allowed + errs = v.validate(fields, existing, OperationUpdate, featuregate.TechPreviewNoUpgrade) + if errs != nil { + t.Errorf("expected mutable override when gate enabled, got: %v", errs) + } +} + +func TestValidationErrors_Error(t *testing.T) { + errs := ValidationErrors{ + {Field: "spec.a", Reason: "blocked"}, + {Field: "spec.b", Reason: "also blocked"}, + } + s := errs.Error() + if s == "" { + t.Error("expected non-empty error string") + } +}