From ef3054413c5718e21ed83832c7c2ffe114ba063b Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Wed, 15 Jul 2026 09:41:43 -0500 Subject: [PATCH 1/6] ROSAENG-61801: Add codegen field validation for cluster and nodepool handlers Integrate hyperfleet-api-codegen (v0.1.7) to enforce write-mode and feature-gate validation on cluster/nodepool mutations. Phases 1, 2, 6 of the codegen integration spec. - Add hyperfleet-api-codegen dependency (Go 1.26.0, k8s v0.36.0) - Add field validation middleware (pkg/middleware/field_validation.go) - Wire validation into ClusterHandler and NodePoolHandler create/update - Add codegen-bump and codegen-verify Makefile targets - Add docs/codegen.md with implementation details and pending decisions Co-Authored-By: Claude Opus 4.6 --- Makefile | 25 ++- docs/codegen.md | 205 ++++++++++++++++++++++++ go.mod | 66 ++++---- go.sum | 169 ++++++++++--------- pkg/clients/maestro/client.go | 2 - pkg/clients/maestro/client_test.go | 1 - pkg/handlers/cluster.go | 52 +++++- pkg/handlers/cluster_test.go | 146 +++++++++++++++-- pkg/handlers/nodepool.go | 75 ++++++++- pkg/handlers/zoa.go | 1 - pkg/middleware/field_validation.go | 53 ++++++ pkg/middleware/field_validation_test.go | 182 +++++++++++++++++++++ pkg/server/server.go | 19 +-- pkg/zoa/audit_store.go | 2 +- pkg/zoa/reconciler.go | 12 +- pkg/zoa/reconciler_test.go | 14 +- pkg/zoa/templates_test.go | 8 +- pkg/zoa/types.go | 42 ++--- test/e2e-zoa/zoa_test.go | 8 +- 19 files changed, 886 insertions(+), 196 deletions(-) create mode 100644 docs/codegen.md create mode 100644 pkg/middleware/field_validation.go create mode 100644 pkg/middleware/field_validation_test.go diff --git a/Makefile b/Makefile index 036f8b74..e41ca3e8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet +.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet codegen-bump codegen-verify BINARY_NAME := rosa-regional-platform-api IMAGE_REPO ?= quay.io/openshift-online/rosa-regional-platform-api @@ -76,11 +76,15 @@ help: @echo " image-e2e-push-multiarch - Build and push E2E test container (multiarch)" @echo "" @echo "Code Generation:" - @echo " deps - Download and tidy dependencies" - @echo " generate - Generate OpenAPI code" + @echo " deps - Download and tidy dependencies" + @echo " generate - Generate OpenAPI code" @echo " generate-swagger - Regenerate swagger-ui.html" @echo "" - @echo " all - Run all checks (deps, fmt, vet, lint, test, build)" + @echo "Codegen Integration:" + @echo " codegen-bump - Update hyperfleet-api-codegen dependency (CODEGEN_VERSION=v0.1.7)" + @echo " codegen-verify - Verify codegen-dependent packages compile" + @echo "" + @echo " all - Run all checks (deps, fmt, vet, lint, test, build)" # Build the binary build: @@ -340,5 +344,18 @@ verify: go mod tidy git diff --exit-code go.mod go.sum +# --- Codegen integration --- + +CODEGEN_VERSION ?= v0.1.7 + +codegen-bump: + go get github.com/cdoan1/hyperfleet-api-codegen@$(CODEGEN_VERSION) + go mod tidy + +codegen-verify: + @echo "Verifying codegen dependency compiles..." + go build ./pkg/middleware/... + go build ./pkg/handlers/... + # All checks all: deps fmt vet lint test build diff --git a/docs/codegen.md b/docs/codegen.md new file mode 100644 index 00000000..16b3d331 --- /dev/null +++ b/docs/codegen.md @@ -0,0 +1,205 @@ +# Codegen Integration: hyperfleet-api-codegen + +**Codegen repo:** `github.com/cdoan1/hyperfleet-api-codegen` (tag: v0.1.7) +**Jira:** [ROSAENG-61801](https://redhat.atlassian.net/browse/ROSAENG-61801) +**Parent:** [ROSAENG-61383](https://redhat.atlassian.net/browse/ROSAENG-61383) + +## What was done + +Phases 1, 2, and 6 of the [integration spec](https://github.com/cdoan1/hyperfleet-api-codegen/blob/main/docs/integration-rosa-hyperfleet-api.md) are complete. The gateway now imports the codegen repo as a Go library and enforces write-mode (mutable/immutable/service-set) and feature-gate validation on cluster and nodepool mutations. + +### Phase 1: Module dependency + +Added `github.com/cdoan1/hyperfleet-api-codegen@v0.1.7` as a direct dependency. This required upgrading: + +- Go: 1.25.4 → 1.26.0 +- k8s.io/apimachinery: v0.34.3 → v0.36.0 +- k8s.io/api: v0.34.3 → v0.36.0 +- k8s.io/client-go: v0.34.3 → v0.36.0 + +The k8s.io/client-go upgrade was required because maestro's client-go v0.34.3 references packages removed in k8s.io/api v0.36.0. + +**Files changed:** `go.mod`, `go.sum` + +### Phase 2: Validation middleware + +#### pkg/middleware/field_validation.go (new) + +Wraps the codegen's `validation.Validator`. Provides two methods: + +- `ValidateCreate(spec, featureSet, enabledGates)` — validates a create request +- `ValidateUpdate(spec, existingSpec, featureSet, enabledGates)` — validates an update request + +Key design: the codegen registry uses dotted paths with `spec.` prefix (e.g., `spec.displayName`, `spec.accountId`). The request body spec is a `map[string]interface{}` with keys like `displayName`. The `flattenWithPrefix("spec", spec)` helper recursively flattens nested maps into dotted-path keys to match the registry format. + +```go +fv := middleware.NewFieldValidator() +err := fv.ValidateCreate(req.Spec, featuregate.Default, nil) +``` + +Imports from codegen: +- `github.com/cdoan1/hyperfleet-api-codegen/pkg/validation` +- `github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate` + +#### pkg/handlers/cluster.go (modified) + +- Added `fieldValidator *middleware.FieldValidator` to `ClusterHandler` struct +- Updated `NewClusterHandler` signature: added `*middleware.FieldValidator` parameter +- `Create`: after JSON decode and basic field checks, calls `ValidateCreate`. Returns 422 on failure. +- `Update`: fetches existing cluster via `hyperfleetClient.GetCluster()`, then calls `ValidateUpdate` with both new and existing specs. Returns 422 on failure. +- Added `writeValidationError` helper returning structured 422 response + +Validation is nil-safe — if `fieldValidator` is nil, validation is skipped. This preserves backward compatibility for tests that pass `nil`. + +422 response format: + +```json +{ + "kind": "Error", + "code": "CLUSTERS-MGMT-VALIDATE-001", + "reason": "Validation failed", + "details": [ + {"field": "spec.accountId", "reason": "field is platform-managed (service-set) and cannot be set by customers"} + ] +} +``` + +#### pkg/handlers/nodepool.go (modified) + +Same pattern as cluster handler: +- Added `fieldValidator` field and updated `NewNodePoolHandler` signature +- Validation calls in `Create` and `Update` +- Added `nodePoolSpecToMap` helper to convert `*types.NodePoolSpec` struct to `map[string]interface{}` for the validator + +#### pkg/server/server.go (modified) + +Creates `middleware.NewFieldValidator()` once and passes it to both `NewClusterHandler` and `NewNodePoolHandler`. + +#### pkg/middleware/field_validation_test.go (new, 12 tests) + +| Test | What it validates | +|------|-------------------| +| MutableFieldAllowed | `displayName` accepted on create | +| ServiceSetFieldRejected | `accountId` rejected on create | +| MultipleServiceSetFieldsRejected | `accountId`, `creatorARN`, `internalId` all rejected | +| UpdateMutableAllowed | `displayName` change accepted on update | +| UpdateServiceSetRejected | `accountId` rejected on update | +| FeatureGatedFieldRejected | `tags` rejected with Default feature set | +| FeatureGatedFieldAllowedWithTechPreview | `tags` accepted with TechPreviewNoUpgrade | +| UnknownFieldAllowed | Fields not in registry pass through | +| NestedServiceSetFieldRejected | `spec.hostedCluster.pullSecret` rejected | +| MixedFields | Only service-set fields error, mutable fields pass | +| FlattenWithPrefix | Verifies dotted-path key generation | +| FlattenWithPrefix_EmptyMap | Empty input produces empty output | + +#### pkg/handlers/cluster_test.go (modified) + +- All `NewClusterHandler` calls updated from 3 to 4 args (added `nil` for fieldValidator) +- Added `TestClusterHandler_Create_ValidationRejectsServiceSetField` — sends `accountId` in spec, expects 422 +- Added `TestClusterHandler_Create_ValidationAllowsMutableFields` — sends `displayName` only, expects 201 + +### Phase 6: Makefile targets + +Added to `Makefile`: + +```makefile +CODEGEN_VERSION ?= v0.1.7 + +codegen-bump: + go get github.com/cdoan1/hyperfleet-api-codegen@$(CODEGEN_VERSION) + go mod tidy + +codegen-verify: + @echo "Verifying codegen dependency compiles..." + go build ./pkg/middleware/... + go build ./pkg/handlers/... +``` + +Usage: + +```bash +make codegen-bump CODEGEN_VERSION=v0.1.8 # upgrade codegen dep +make codegen-verify # verify codegen packages compile +``` + +## What remains + +| Phase | Description | Effort | Notes | +|-------|-------------|--------|-------| +| 3 | Replace hardcoded service-set injection with codegen conversion functions | Small | Replace manual `req.Spec["cloudUrl"] = ...` in cluster.go with `conversion.UnprojectCluster()` | +| 4 | Migrate to typed specs | Large | Replace `map[string]interface{}` with codegen REST types (`rest.ClusterSpec`). Touches every handler, client, and test that accesses spec fields. | +| 5 | OpenAPI spec alignment | Medium | Replace freeform `spec: object` in openapi.yaml with schemas generated from codegen | + +Recommended order: 3 → 5 → 4. Phases 2 and 3 work with existing `map[string]interface{}` types. Phase 4 is the largest change and can be deferred. + +## How to recreate this work + +Given a clean `main` branch, an AI agent can reproduce Phases 1, 2, and 6 with these instructions: + +1. **Add the codegen dependency:** + ```bash + go get github.com/cdoan1/hyperfleet-api-codegen@v0.1.7 + ``` + If `go mod tidy` fails on k8s dependency conflicts, upgrade k8s.io/client-go to match the k8s.io/apimachinery version pulled in by the codegen: + ```bash + go get k8s.io/client-go@v0.36.0 + go mod tidy + ``` + +2. **Create `pkg/middleware/field_validation.go`** — a `FieldValidator` struct wrapping `validation.NewValidator()` with `ValidateCreate` and `ValidateUpdate` methods. Include `flattenWithPrefix` to convert `map[string]interface{}` keys to dotted `spec.*` paths matching the codegen registry. + +3. **Wire into handlers** — add `fieldValidator *middleware.FieldValidator` to `ClusterHandler` and `NodePoolHandler` structs. Call `ValidateCreate`/`ValidateUpdate` after JSON decode, before business logic. Return 422 with field-level details on failure. Use nil-checks so tests can pass `nil` to skip validation. + +4. **Update `pkg/server/server.go`** — create `middleware.NewFieldValidator()` and pass to both handler constructors. + +5. **Add tests** — middleware unit tests covering mutable/service-set/immutable/feature-gated/unknown/nested fields. Handler integration tests for 422 on service-set fields and 201 on valid fields. + +6. **Add Makefile targets** — `codegen-bump` and `codegen-verify`. + +7. **Verify** — `go build ./...`, `make test`, `make lint` should all pass. + +## Pending design decision: where should the codegen code live? + +**Status:** Needs team input + +The codegen repo (`github.com/cdoan1/hyperfleet-api-codegen`) is currently only used by this API project. Should we merge it into this repo, keep it separate, or do a partial merge? + +### The two halves of the codegen repo + +The codegen repo contains two distinct categories of code: + +| Category | Packages | Dependencies | Used when | +|----------|----------|-------------|-----------| +| **Runtime libraries** | `pkg/registry/`, `pkg/validation/`, `pkg/featuregate/`, `pkg/conversion/` | Lightweight (k8s.io/apimachinery only) | Every API request — imported by handlers and middleware | +| **Generator tools** | `cmd/passthrough-gen`, `cmd/marker-scanner`, `cmd/openapi-gen`, `cmd/conversion-gen`, `cmd/crd-variants`, `cmd/verify-configuration` | Heavy (openshift/hypershift/api, AST parsing, controller-runtime, code-generator) | Only when HyperShift CRDs change — run offline to regenerate types | + +### Options + +| Option | Description | Pros | Cons | +|--------|-------------|------|------| +| **A. Keep separate** (current) | Codegen stays in its own repo, imported as `go get` dependency | Clean separation; heavy generator deps stay out of gateway go.mod; independent release cycle | Two repos to maintain; version coordination on every bump; k8s dependency alignment issues (already hit in Phase 1) | +| **B. Merge everything** | Move entire codegen repo into this repo (e.g., `pkg/codegen/` or `internal/codegen/`) | Single repo; no version coordination; easier cross-cutting changes | Gateway go.mod inherits all generator deps (HyperShift API, controller-runtime, etc.) even though they're only needed at generation time; heavier builds; worse k8s version conflicts | +| **C. Partial merge** (recommended) | Move runtime libraries into this repo (e.g., `internal/codegen/registry/`, `internal/codegen/validation/`). Keep generator tools in the separate repo or as a Go sub-module. | Runtime code is local — no external dep for request-path code; generator deps stay isolated; simpler day-to-day development | Still two places to look for codegen-related code; generator tool changes still require a separate workflow | +| **D. Go workspace** | Use a Go workspace (`go.work`) with both repos checked out side-by-side | Develop across both repos without publishing versions; each repo keeps its own go.mod | Requires both repos checked out locally; CI needs workspace setup; go.work files shouldn't be committed | + +### Key considerations + +- **Dependency weight:** The generator tools pull in `openshift/hypershift/api`, `controller-runtime`, and k8s code-generator packages. Merging these into the gateway caused k8s.io/api version conflicts during Phase 1 (removed packages in v0.36.0 broke maestro's client-go v0.34.3). This problem gets worse if generators and API share a go.mod. +- **Change frequency:** Generator tools only run when upstream HyperShift CRDs change. Runtime libraries change when validation rules or field metadata evolve. The API server changes frequently. Different cadences favor separation. +- **Team workflow:** A single repo is simpler for code review and CI. Two repos mean PRs that span both are harder to coordinate. +- **Future consumers:** If another project ever needs the codegen output, a separate repo is easier to share. If this API is the only consumer, the separate repo is overhead. + +### Decision needed + +Team should weigh in on which option to pursue before Phase 3 work begins, since Phase 3 (conversion functions) and Phase 4 (typed specs) will significantly increase the coupling between the two codebases. + +## Codegen version bump workflow + +When the codegen repo releases a new version: + +```bash +make codegen-bump CODEGEN_VERSION=v0.1.8 +go build ./... # compile errors surface breaking changes +make test # run full test suite +make codegen-verify # verify codegen-dependent packages +``` diff --git a/go.mod b/go.mod index 11ad4311..c758f6d0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/openshift/rosa-regional-platform-api -go 1.25.4 +go 1.26.0 require ( github.com/aws/aws-sdk-go-v2 v1.41.12 @@ -10,6 +10,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.55.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2 github.com/aws/aws-sdk-go-v2/service/verifiedpermissions v1.24.0 + github.com/cdoan1/hyperfleet-api-codegen v0.1.7 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/onsi/ginkgo/v2 v2.28.1 @@ -19,7 +20,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 - k8s.io/apimachinery v0.34.3 + k8s.io/apimachinery v0.36.0 open-cluster-management.io/api v1.2.0 open-cluster-management.io/sdk-go v1.1.1-0.20260128013609-7a2e40f02c1d ) @@ -51,28 +52,35 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/getsentry/sentry-go v0.20.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.23.1 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.2.5 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/openshift-online/ocm-sdk-go v0.1.493 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect @@ -84,30 +92,30 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/api v0.34.3 // indirect - k8s.io/client-go v0.34.3 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/api v0.36.0 // indirect + k8s.io/client-go v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // 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.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 69bb7a57..9c381ea4 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/cdoan1/hyperfleet-api-codegen v0.1.7 h1:yf+3cdr0pPLDf55nA+ahlkaAoYFliX9C85kL4p+c5Vo= +github.com/cdoan1/hyperfleet-api-codegen v0.1.7/go.mod h1:bRno7UADIb8bHZyW84xUz+qBhC5FiGSQyGc9sZ3GsmY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= @@ -63,14 +65,14 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -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/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getsentry/sentry-go v0.20.0 h1:bwXW98iMRIWxn+4FgPW7vMrjmbym6HblXALmhjHmQaQ= github.com/getsentry/sentry-go v0.20.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -85,24 +87,50 @@ 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-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -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.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -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/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/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= @@ -114,14 +142,10 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= 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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -130,8 +154,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -167,8 +189,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= @@ -195,8 +217,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -215,93 +235,68 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +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/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= -k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= -k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= -k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= -k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +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/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +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-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= open-cluster-management.io/api v1.2.0 h1:+yeQgJiErrur5S4s205UM37EcZ2XbC9pFSm0xgV5/hU= open-cluster-management.io/api v1.2.0/go.mod h1:YcmA6SpGEekIMxdoeVIIyOaBhMA6ImWRLXP4g8n8T+4= open-cluster-management.io/sdk-go v1.1.1-0.20260128013609-7a2e40f02c1d h1:wacUVN8Vw0Wr3dzEjV4rDUQ/RRW+NwyiiJnWx3iajAk= open-cluster-management.io/sdk-go v1.1.1-0.20260128013609-7a2e40f02c1d/go.mod h1:OHM74Kw1gh9RHxg7QjJlGXCDlPm7x2CtCkejHSdczs4= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +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.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +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/pkg/clients/maestro/client.go b/pkg/clients/maestro/client.go index 227809b9..cdd20798 100644 --- a/pkg/clients/maestro/client.go +++ b/pkg/clients/maestro/client.go @@ -578,5 +578,3 @@ func (c *Client) GetNodePoolStatus(ctx context.Context, accountID, nodePoolID st c.logger.Debug("getting nodepool status", "account_id", accountID, "nodepool_id", nodePoolID) return nil, fmt.Errorf("not implemented") } - - diff --git a/pkg/clients/maestro/client_test.go b/pkg/clients/maestro/client_test.go index 9ce568aa..3471f375 100644 --- a/pkg/clients/maestro/client_test.go +++ b/pkg/clients/maestro/client_test.go @@ -627,4 +627,3 @@ func TestError_Error(t *testing.T) { t.Errorf("expected error message='This is a test error', got %s", err.Error()) } } - diff --git a/pkg/handlers/cluster.go b/pkg/handlers/cluster.go index a9b72824..f80366fc 100644 --- a/pkg/handlers/cluster.go +++ b/pkg/handlers/cluster.go @@ -12,20 +12,25 @@ import ( "github.com/openshift/rosa-regional-platform-api/pkg/clients/maestro" "github.com/openshift/rosa-regional-platform-api/pkg/middleware" "github.com/openshift/rosa-regional-platform-api/pkg/types" + + "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" + "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" ) // ClusterHandler handles cluster-related HTTP requests type ClusterHandler struct { hyperfleetClient *hyperfleet.Client maestroClient *maestro.Client + fieldValidator *middleware.FieldValidator logger *slog.Logger } // NewClusterHandler creates a new cluster handler -func NewClusterHandler(hyperfleetClient *hyperfleet.Client, maestroClient *maestro.Client, logger *slog.Logger) *ClusterHandler { +func NewClusterHandler(hyperfleetClient *hyperfleet.Client, maestroClient *maestro.Client, fieldValidator *middleware.FieldValidator, logger *slog.Logger) *ClusterHandler { return &ClusterHandler{ hyperfleetClient: hyperfleetClient, maestroClient: maestroClient, + fieldValidator: fieldValidator, logger: logger, } } @@ -93,6 +98,13 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { return } + if h.fieldValidator != nil { + if err := h.fieldValidator.ValidateCreate(req.Spec, featuregate.Default, nil); err != nil { + h.writeValidationError(w, err) + return + } + } + // Get CloudFront URL from the first management cluster before creating the cluster managementClusters, err := h.maestroClient.ListConsumers(ctx, 1, 1) if err != nil { @@ -211,6 +223,23 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { return } + if h.fieldValidator != nil { + existing, err := h.hyperfleetClient.GetCluster(ctx, accountID, clusterID) + if err != nil { + if hyperfleet.IsNotFound(err) { + h.writeError(w, http.StatusNotFound, "CLUSTERS-MGMT-UPDATE-003", "Cluster not found") + return + } + h.logger.Error("failed to get cluster for validation", "error", err) + h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-UPDATE-005", "Failed to validate update") + return + } + if err := h.fieldValidator.ValidateUpdate(req.Spec, existing.Spec, featuregate.Default, nil); err != nil { + h.writeValidationError(w, err) + return + } + } + h.logger.Info("updating cluster", "account_id", accountID, "cluster_id", clusterID) cluster, err := h.hyperfleetClient.UpdateCluster(ctx, accountID, clusterID, &req) @@ -288,6 +317,27 @@ func (h *ClusterHandler) writeJSON(w http.ResponseWriter, status int, data inter _ = json.NewEncoder(w).Encode(data) } +func (h *ClusterHandler) writeValidationError(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + resp := map[string]interface{}{ + "kind": "Error", + "code": "CLUSTERS-MGMT-VALIDATE-001", + "reason": "Validation failed", + } + if valErrs, ok := err.(validation.ValidationErrors); ok { + details := make([]map[string]string, 0, len(valErrs)) + for _, ve := range valErrs { + details = append(details, map[string]string{ + "field": ve.FieldPath, + "reason": ve.Reason, + }) + } + resp["details"] = details + } + _ = json.NewEncoder(w).Encode(resp) +} + func (h *ClusterHandler) writeError(w http.ResponseWriter, status int, code, reason string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/pkg/handlers/cluster_test.go b/pkg/handlers/cluster_test.go index 10a51f72..7373c072 100644 --- a/pkg/handlers/cluster_test.go +++ b/pkg/handlers/cluster_test.go @@ -63,7 +63,7 @@ func TestClusterHandler_List_Success(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodGet, "/api/v0/clusters", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -142,7 +142,7 @@ func TestClusterHandler_List_WithPagination(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodGet, "/api/v0/clusters"+tt.queryParams, nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -176,7 +176,7 @@ func TestClusterHandler_List_Error(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodGet, "/api/v0/clusters", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -280,7 +280,7 @@ func TestClusterHandler_Create_Success(t *testing.T) { BaseURL: maestroServer.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, maestroClient, logger) + handler := NewClusterHandler(hfClient, maestroClient, nil, logger) reqBody := map[string]interface{}{ "name": "new-cluster", @@ -333,7 +333,7 @@ func TestClusterHandler_Create_InvalidJSON(t *testing.T) { BaseURL: "http://localhost:8080", Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodPost, "/api/v0/clusters", bytes.NewReader([]byte("invalid json"))) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -383,7 +383,7 @@ func TestClusterHandler_Create_MissingFields(t *testing.T) { BaseURL: "http://localhost:8080", Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) body, _ := json.Marshal(tt.reqBody) req := httptest.NewRequest(http.MethodPost, "/api/v0/clusters", bytes.NewReader(body)) @@ -475,7 +475,7 @@ func TestClusterHandler_Create_WithExistingPlacement(t *testing.T) { BaseURL: maestroServer.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, maestroClient, logger) + handler := NewClusterHandler(hfClient, maestroClient, nil, logger) reqBody := map[string]interface{}{ "name": "new-cluster", @@ -549,7 +549,7 @@ func TestClusterHandler_Create_NoManagementClusterName(t *testing.T) { BaseURL: maestroServer.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, maestroClient, logger) + handler := NewClusterHandler(hfClient, maestroClient, nil, logger) reqBody := map[string]interface{}{ "name": "new-cluster", @@ -614,7 +614,7 @@ func TestClusterHandler_Get_Success(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodGet, "/api/v0/clusters/cluster-123", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -658,7 +658,7 @@ func TestClusterHandler_Get_NotFound(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodGet, "/api/v0/clusters/cluster-999", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -698,7 +698,7 @@ func TestClusterHandler_Delete_Success(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodDelete, "/api/v0/clusters/cluster-123", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -742,7 +742,7 @@ func TestClusterHandler_Delete_NotFound(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodDelete, "/api/v0/clusters/cluster-999", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -818,7 +818,7 @@ func TestClusterHandler_GetStatus_Success(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodGet, "/api/v0/clusters/cluster-123/statuses", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -890,7 +890,7 @@ func TestClusterHandler_GetStatus_NotFound(t *testing.T) { BaseURL: server.URL, Timeout: 30 * time.Second, }, logger) - handler := NewClusterHandler(hfClient, nil, logger) + handler := NewClusterHandler(hfClient, nil, nil, logger) req := httptest.NewRequest(http.MethodGet, "/api/v0/clusters/cluster-999/statuses", nil) ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") @@ -915,3 +915,121 @@ func TestClusterHandler_GetStatus_NotFound(t *testing.T) { t.Errorf("expected code=CLUSTERS-MGMT-STATUS-001, got %v", errorResp["code"]) } } + +// TestClusterHandler_Create_ValidationRejectsServiceSetField tests that the validator +// rejects service-set fields (like accountId) in the create request spec. +func TestClusterHandler_Create_ValidationRejectsServiceSetField(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + hfClient := hyperfleet.NewClient(config.HyperfleetConfig{ + BaseURL: "http://localhost:8080", + Timeout: 30 * time.Second, + }, logger) + fv := middleware.NewFieldValidator() + handler := NewClusterHandler(hfClient, nil, fv, logger) + + reqBody := map[string]interface{}{ + "name": "new-cluster", + "spec": map[string]interface{}{ + "displayName": "My Cluster", + "accountId": "123456789012", + }, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/api/v0/clusters", bytes.NewReader(body)) + ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.Create(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("expected status 422, got %d", w.Code) + } + + var errorResp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + + if errorResp["code"] != "CLUSTERS-MGMT-VALIDATE-001" { + t.Errorf("expected code=CLUSTERS-MGMT-VALIDATE-001, got %v", errorResp["code"]) + } + + details, ok := errorResp["details"].([]interface{}) + if !ok || len(details) == 0 { + t.Fatal("expected details array with at least one error") + } + + detail := details[0].(map[string]interface{}) + if detail["field"] != "spec.accountId" { + t.Errorf("expected field=spec.accountId, got %v", detail["field"]) + } +} + +// TestClusterHandler_Create_ValidationAllowsMutableFields tests that the validator +// allows mutable fields like displayName in the create request spec. +func TestClusterHandler_Create_ValidationAllowsMutableFields(t *testing.T) { + now := time.Now() + + hfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/hyperfleet/v1/clusters" { + resp := hyperfleet.HFCluster{ + ID: "cluster-123", + Name: "new-cluster", + Labels: map[string]string{}, + Spec: map[string]interface{}{"displayName": "My Cluster"}, + Generation: 1, + CreatedBy: "user@example.com", + CreatedAt: now, + UpdatedAt: now, + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + } + })) + defer hfServer.Close() + + maestroServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "kind": "ConsumerList", "page": 1, "size": 1, "total": 1, + "items": []map[string]interface{}{{ + "id": "mgmt-1", "name": "mgmt-cluster", + "labels": map[string]string{"cloudfront_url": "https://cf.example.com"}, + }}, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + })) + defer maestroServer.Close() + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + hfClient := hyperfleet.NewClient(config.HyperfleetConfig{ + BaseURL: hfServer.URL, Timeout: 30 * time.Second, + }, logger) + maestroClient := maestro.NewClient(config.MaestroConfig{ + BaseURL: maestroServer.URL, Timeout: 30 * time.Second, + }, logger) + fv := middleware.NewFieldValidator() + handler := NewClusterHandler(hfClient, maestroClient, fv, logger) + + reqBody := map[string]interface{}{ + "name": "new-cluster", + "spec": map[string]interface{}{ + "displayName": "My Cluster", + }, + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/api/v0/clusters", bytes.NewReader(body)) + ctx := context.WithValue(req.Context(), middleware.ContextKeyAccountID, "test-account-123") + ctx = context.WithValue(ctx, middleware.ContextKeyCallerARN, "arn:aws:iam::test-account-123:user/test") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.Create(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d (body: %s)", w.Code, w.Body.String()) + } +} diff --git a/pkg/handlers/nodepool.go b/pkg/handlers/nodepool.go index 7f185e09..80667e1f 100644 --- a/pkg/handlers/nodepool.go +++ b/pkg/handlers/nodepool.go @@ -10,19 +10,24 @@ import ( "github.com/openshift/rosa-regional-platform-api/pkg/clients/maestro" "github.com/openshift/rosa-regional-platform-api/pkg/middleware" "github.com/openshift/rosa-regional-platform-api/pkg/types" + + "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" + "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" ) // NodePoolHandler handles nodepool-related HTTP requests type NodePoolHandler struct { - maestroClient *maestro.Client - logger *slog.Logger + maestroClient *maestro.Client + fieldValidator *middleware.FieldValidator + logger *slog.Logger } // NewNodePoolHandler creates a new nodepool handler -func NewNodePoolHandler(maestroClient *maestro.Client, logger *slog.Logger) *NodePoolHandler { +func NewNodePoolHandler(maestroClient *maestro.Client, fieldValidator *middleware.FieldValidator, logger *slog.Logger) *NodePoolHandler { return &NodePoolHandler{ - maestroClient: maestroClient, - logger: logger, + maestroClient: maestroClient, + fieldValidator: fieldValidator, + logger: logger, } } @@ -89,6 +94,14 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { return } + if h.fieldValidator != nil { + specMap := nodePoolSpecToMap(req.Spec) + if err := h.fieldValidator.ValidateCreate(specMap, featuregate.Default, nil); err != nil { + h.writeValidationError(w, err) + return + } + } + h.logger.Info("creating nodepool", "account_id", accountID, "cluster_id", req.ClusterID, "nodepool_name", req.Name) nodepool, err := h.maestroClient.CreateNodePool(ctx, accountID, userEmail, &req) @@ -142,6 +155,14 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { return } + if h.fieldValidator != nil { + specMap := nodePoolSpecToMap(req.Spec) + if err := h.fieldValidator.ValidateUpdate(specMap, nil, featuregate.Default, nil); err != nil { + h.writeValidationError(w, err) + return + } + } + h.logger.Info("updating nodepool", "account_id", accountID, "nodepool_id", nodepoolID) nodepool, err := h.maestroClient.UpdateNodePool(ctx, accountID, nodepoolID, &req) @@ -216,6 +237,50 @@ func (h *NodePoolHandler) writeJSON(w http.ResponseWriter, status int, data inte _ = json.NewEncoder(w).Encode(data) } +func (h *NodePoolHandler) writeValidationError(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + resp := map[string]interface{}{ + "kind": "Error", + "code": "NODEPOOLS-MGMT-VALIDATE-001", + "reason": "Validation failed", + } + if valErrs, ok := err.(validation.ValidationErrors); ok { + details := make([]map[string]string, 0, len(valErrs)) + for _, ve := range valErrs { + details = append(details, map[string]string{ + "field": ve.FieldPath, + "reason": ve.Reason, + }) + } + resp["details"] = details + } + _ = json.NewEncoder(w).Encode(resp) +} + +func nodePoolSpecToMap(spec *types.NodePoolSpec) map[string]interface{} { + if spec == nil { + return nil + } + m := make(map[string]interface{}) + if spec.Replicas != 0 { + m["replicas"] = spec.Replicas + } + if spec.NodeDrainTimeout != "" { + m["nodeDrainTimeout"] = spec.NodeDrainTimeout + } + if spec.Management != nil { + m["management"] = spec.Management + } + if spec.Platform != nil { + m["platform"] = spec.Platform + } + if spec.Release != nil { + m["release"] = spec.Release + } + return m +} + func (h *NodePoolHandler) writeError(w http.ResponseWriter, status int, code, reason string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/pkg/handlers/zoa.go b/pkg/handlers/zoa.go index 2864c4a9..ad288d93 100644 --- a/pkg/handlers/zoa.go +++ b/pkg/handlers/zoa.go @@ -710,4 +710,3 @@ func (h *ZoaHandler) AuditList(w http.ResponseWriter, r *http.Request) { "total": len(entries), }) } - diff --git a/pkg/middleware/field_validation.go b/pkg/middleware/field_validation.go new file mode 100644 index 00000000..5e234612 --- /dev/null +++ b/pkg/middleware/field_validation.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" + "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" +) + +type FieldValidator struct { + validator *validation.Validator +} + +func NewFieldValidator() *FieldValidator { + return &FieldValidator{ + validator: validation.NewValidator(), + } +} + +func (fv *FieldValidator) ValidateCreate(spec map[string]interface{}, featureSet featuregate.FeatureSet, enabledGates []string) error { + fields := flattenWithPrefix("spec", spec) + return fv.validator.Validate(&validation.Request{ + Operation: validation.OperationCreate, + Fields: fields, + FeatureSet: featureSet, + EnabledGates: enabledGates, + }) +} + +func (fv *FieldValidator) ValidateUpdate(spec, existingSpec map[string]interface{}, featureSet featuregate.FeatureSet, enabledGates []string) error { + fields := flattenWithPrefix("spec", spec) + existing := flattenWithPrefix("spec", existingSpec) + return fv.validator.Validate(&validation.Request{ + Operation: validation.OperationUpdate, + Fields: fields, + ExistingFields: existing, + FeatureSet: featureSet, + EnabledGates: enabledGates, + }) +} + +func flattenWithPrefix(prefix string, m map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range m { + key := prefix + "." + k + if nested, ok := v.(map[string]interface{}); ok { + for nk, nv := range flattenWithPrefix(key, nested) { + result[nk] = nv + } + } else { + result[key] = v + } + } + return result +} diff --git a/pkg/middleware/field_validation_test.go b/pkg/middleware/field_validation_test.go new file mode 100644 index 00000000..e1fe73a4 --- /dev/null +++ b/pkg/middleware/field_validation_test.go @@ -0,0 +1,182 @@ +package middleware + +import ( + "testing" + + "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" + "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" +) + +func TestFieldValidator_ValidateCreate_MutableFieldAllowed(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "displayName": "my-cluster", + } + if err := fv.ValidateCreate(spec, featuregate.Default, nil); err != nil { + t.Errorf("expected mutable field to be allowed on create, got: %v", err) + } +} + +func TestFieldValidator_ValidateCreate_ServiceSetFieldRejected(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "accountId": "123456789012", + } + err := fv.ValidateCreate(spec, featuregate.Default, nil) + if err == nil { + t.Fatal("expected service-set field to be rejected on create") + } + valErrs, ok := err.(validation.ValidationErrors) + if !ok { + t.Fatalf("expected ValidationErrors, got %T", err) + } + if len(valErrs) != 1 { + t.Fatalf("expected 1 validation error, got %d", len(valErrs)) + } + if valErrs[0].FieldPath != "spec.accountId" { + t.Errorf("expected field path spec.accountId, got %s", valErrs[0].FieldPath) + } +} + +func TestFieldValidator_ValidateCreate_MultipleServiceSetFieldsRejected(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "accountId": "123456789012", + "creatorARN": "arn:aws:iam::123456789012:user/someone", + "internalId": "abc-123", + } + err := fv.ValidateCreate(spec, featuregate.Default, nil) + if err == nil { + t.Fatal("expected service-set fields to be rejected") + } + valErrs, ok := err.(validation.ValidationErrors) + if !ok { + t.Fatalf("expected ValidationErrors, got %T", err) + } + if len(valErrs) != 3 { + t.Errorf("expected 3 validation errors, got %d: %v", len(valErrs), err) + } +} + +func TestFieldValidator_ValidateUpdate_MutableFieldAllowed(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "displayName": "new-name", + } + existingSpec := map[string]interface{}{ + "displayName": "old-name", + } + if err := fv.ValidateUpdate(spec, existingSpec, featuregate.Default, nil); err != nil { + t.Errorf("expected mutable field to be allowed on update, got: %v", err) + } +} + +func TestFieldValidator_ValidateUpdate_ServiceSetFieldRejected(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "accountId": "new-account", + } + existingSpec := map[string]interface{}{ + "accountId": "old-account", + } + err := fv.ValidateUpdate(spec, existingSpec, featuregate.Default, nil) + if err == nil { + t.Fatal("expected service-set field to be rejected on update") + } +} + +func TestFieldValidator_ValidateCreate_FeatureGatedFieldRejected(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "tags": map[string]string{"env": "prod"}, + } + err := fv.ValidateCreate(spec, featuregate.Default, nil) + if err == nil { + t.Fatal("expected feature-gated field to be rejected when gate not enabled") + } +} + +func TestFieldValidator_ValidateCreate_FeatureGatedFieldAllowedWithTechPreview(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "tags": map[string]string{"env": "prod"}, + } + if err := fv.ValidateCreate(spec, featuregate.TechPreviewNoUpgrade, nil); err != nil { + t.Errorf("expected feature-gated field to be allowed with TechPreview, got: %v", err) + } +} + +func TestFieldValidator_ValidateCreate_UnknownFieldAllowed(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "someRandomField": "value", + } + if err := fv.ValidateCreate(spec, featuregate.Default, nil); err != nil { + t.Errorf("expected unknown field to pass through, got: %v", err) + } +} + +func TestFieldValidator_ValidateCreate_NestedServiceSetFieldRejected(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "hostedCluster": map[string]interface{}{ + "pullSecret": "my-secret", + }, + } + err := fv.ValidateCreate(spec, featuregate.Default, nil) + if err == nil { + t.Fatal("expected nested service-set field spec.hostedCluster.pullSecret to be rejected") + } +} + +func TestFieldValidator_ValidateCreate_MixedFields(t *testing.T) { + fv := NewFieldValidator() + spec := map[string]interface{}{ + "displayName": "my-cluster", + "accountId": "123456789012", + } + err := fv.ValidateCreate(spec, featuregate.Default, nil) + if err == nil { + t.Fatal("expected validation error for service-set field even when mixed with valid fields") + } + valErrs, ok := err.(validation.ValidationErrors) + if !ok { + t.Fatalf("expected ValidationErrors, got %T", err) + } + if len(valErrs) != 1 { + t.Errorf("expected exactly 1 error (for accountId only), got %d: %v", len(valErrs), err) + } +} + +func TestFlattenWithPrefix(t *testing.T) { + input := map[string]interface{}{ + "displayName": "my-cluster", + "hostedCluster": map[string]interface{}{ + "channel": "stable", + "fips": true, + }, + } + result := flattenWithPrefix("spec", input) + + expected := map[string]interface{}{ + "spec.displayName": "my-cluster", + "spec.hostedCluster.channel": "stable", + "spec.hostedCluster.fips": true, + } + + if len(result) != len(expected) { + t.Fatalf("expected %d keys, got %d: %v", len(expected), len(result), result) + } + for k, v := range expected { + if result[k] != v { + t.Errorf("key %s: expected %v, got %v", k, v, result[k]) + } + } +} + +func TestFlattenWithPrefix_EmptyMap(t *testing.T) { + result := flattenWithPrefix("spec", map[string]interface{}{}) + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 7964cb85..7c1c77bc 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -24,13 +24,13 @@ import ( // Server represents the API server type Server struct { - cfg *config.Config - logger *slog.Logger - apiServer *http.Server - healthServer *http.Server - metricsServer *http.Server - healthHandler *apphandlers.HealthHandler - zoaReconciler *zoa.Reconciler + cfg *config.Config + logger *slog.Logger + apiServer *http.Server + healthServer *http.Server + metricsServer *http.Server + healthHandler *apphandlers.HealthHandler + zoaReconciler *zoa.Reconciler } // New creates a new Server instance @@ -49,8 +49,9 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) { mgmtClusterHandler := apphandlers.NewManagementClusterHandler(maestroClient, logger) resourceBundleHandler := apphandlers.NewResourceBundleHandler(maestroClient, logger) workHandler := apphandlers.NewWorkHandler(maestroClient, logger) - clusterHandler := apphandlers.NewClusterHandler(hyperfleetClient, maestroClient, logger) - nodePoolHandler := apphandlers.NewNodePoolHandler(maestroClient, logger) + fieldValidator := middleware.NewFieldValidator() + clusterHandler := apphandlers.NewClusterHandler(hyperfleetClient, maestroClient, fieldValidator, logger) + nodePoolHandler := apphandlers.NewNodePoolHandler(maestroClient, fieldValidator, logger) // Create legacy authorization middleware (for non-authz routes) authMiddleware := middleware.NewAuthorization(cfg.AllowedAccounts, logger) diff --git a/pkg/zoa/audit_store.go b/pkg/zoa/audit_store.go index cff489c5..5322a478 100644 --- a/pkg/zoa/audit_store.go +++ b/pkg/zoa/audit_store.go @@ -147,7 +147,7 @@ func (s *DynamoAuditStore) List(ctx context.Context, accountID string, limit int input := &dynamodb.QueryInput{ TableName: aws.String(s.tableName), KeyConditionExpression: aws.String(keyCondition), - ExpressionAttributeNames: exprNames, + ExpressionAttributeNames: exprNames, ExpressionAttributeValues: exprValues, ScanIndexForward: aws.Bool(false), Limit: aws.Int32(int32(limit)), diff --git a/pkg/zoa/reconciler.go b/pkg/zoa/reconciler.go index bb2fd36a..8cddae31 100644 --- a/pkg/zoa/reconciler.go +++ b/pkg/zoa/reconciler.go @@ -245,12 +245,12 @@ func (r *Reconciler) isTimedOut(exec *Execution) bool { // jobResult holds parsed completion info from ManifestWork feedback for both Jobs. type jobResult struct { - taSucceeded bool - taFailed bool - uploadSucceeded bool - uploadFailed bool - applied bool - runnerStartTime string + taSucceeded bool + taFailed bool + uploadSucceeded bool + uploadFailed bool + applied bool + runnerStartTime string runnerCompletionTime string uploadCompletionTime string } diff --git a/pkg/zoa/reconciler_test.go b/pkg/zoa/reconciler_test.go index 6875205e..c823a44a 100644 --- a/pkg/zoa/reconciler_test.go +++ b/pkg/zoa/reconciler_test.go @@ -56,13 +56,13 @@ func (m *mockMaestroClient) DeleteManifestWork(ctx context.Context, clusterName, } type mockExecutionStore struct { - createFunc func(ctx context.Context, exec *Execution) error - getFunc func(ctx context.Context, executionID string) (*Execution, error) - listFunc func(ctx context.Context, accountID string, limit int, filter *ListFilter) ([]*Execution, error) - updateStatusFunc func(ctx context.Context, executionID string, status ExecutionStatus, completedAt string, duration int) error - updateCompletionFunc func(ctx context.Context, executionID string, status ExecutionStatus, completedAt string, duration int, runnerSeconds int, uploadSeconds int, outputStatus OutputStatus) error - updateMWNameFunc func(ctx context.Context, executionID, mwName string) error - listPendingFunc func(ctx context.Context) ([]*Execution, error) + createFunc func(ctx context.Context, exec *Execution) error + getFunc func(ctx context.Context, executionID string) (*Execution, error) + listFunc func(ctx context.Context, accountID string, limit int, filter *ListFilter) ([]*Execution, error) + updateStatusFunc func(ctx context.Context, executionID string, status ExecutionStatus, completedAt string, duration int) error + updateCompletionFunc func(ctx context.Context, executionID string, status ExecutionStatus, completedAt string, duration int, runnerSeconds int, uploadSeconds int, outputStatus OutputStatus) error + updateMWNameFunc func(ctx context.Context, executionID, mwName string) error + listPendingFunc func(ctx context.Context) ([]*Execution, error) } func (m *mockExecutionStore) Create(ctx context.Context, exec *Execution) error { diff --git a/pkg/zoa/templates_test.go b/pkg/zoa/templates_test.go index 60f61653..a017d329 100644 --- a/pkg/zoa/templates_test.go +++ b/pkg/zoa/templates_test.go @@ -178,10 +178,10 @@ func TestBuildManifestWork_NamespaceScoped(t *testing.T) { func TestBuildManifestWork_AWSScope_NoSAManifest(t *testing.T) { tmpl := &TATemplate{ - Name: "describe_instance", - Scope: "aws-api", - Type: "read", - RBAC: nil, + Name: "describe_instance", + Scope: "aws-api", + Type: "read", + RBAC: nil, Script: "aws ec2 describe-instances > /artifacts/output.json\n", } diff --git a/pkg/zoa/types.go b/pkg/zoa/types.go index 548232c3..0e749050 100644 --- a/pkg/zoa/types.go +++ b/pkg/zoa/types.go @@ -45,32 +45,32 @@ const ( // Execution represents a single Trusted Action execution stored in DynamoDB. type Execution struct { - ExecutionID string `dynamodbav:"executionId" json:"id"` - AccountID string `dynamodbav:"accountId" json:"account_id,omitempty"` - CallerARN string `dynamodbav:"callerArn" json:"caller_arn,omitempty"` - Operator string `dynamodbav:"operator" json:"operator,omitempty"` - Action string `dynamodbav:"action" json:"action"` - ExecutedAction string `dynamodbav:"executedAction,omitempty" json:"executed_action,omitempty"` - DryRun bool `dynamodbav:"dryRun" json:"dry_run"` - Force bool `dynamodbav:"force" json:"force"` - TargetCluster string `dynamodbav:"targetCluster" json:"target_cluster"` - Scope string `dynamodbav:"scope" json:"scope"` - Type string `dynamodbav:"type" json:"type,omitempty"` + ExecutionID string `dynamodbav:"executionId" json:"id"` + AccountID string `dynamodbav:"accountId" json:"account_id,omitempty"` + CallerARN string `dynamodbav:"callerArn" json:"caller_arn,omitempty"` + Operator string `dynamodbav:"operator" json:"operator,omitempty"` + Action string `dynamodbav:"action" json:"action"` + ExecutedAction string `dynamodbav:"executedAction,omitempty" json:"executed_action,omitempty"` + DryRun bool `dynamodbav:"dryRun" json:"dry_run"` + Force bool `dynamodbav:"force" json:"force"` + TargetCluster string `dynamodbav:"targetCluster" json:"target_cluster"` + Scope string `dynamodbav:"scope" json:"scope"` + Type string `dynamodbav:"type" json:"type,omitempty"` Params map[string]string `dynamodbav:"params,omitempty" json:"params,omitempty"` Jira string `dynamodbav:"jira" json:"jira"` ApprovalState ApprovalState `dynamodbav:"approvalState" json:"approval_state"` Revision string `dynamodbav:"revision,omitempty" json:"revision,omitempty"` Status ExecutionStatus `dynamodbav:"status" json:"status"` - ManifestWorkName string `dynamodbav:"manifestWorkName,omitempty" json:"manifest_work_name,omitempty"` - OutputPath string `dynamodbav:"outputPath,omitempty" json:"output_path,omitempty"` - OutputStatus OutputStatus `dynamodbav:"outputStatus,omitempty" json:"output_status,omitempty"` - CreatedAt string `dynamodbav:"createdAt" json:"created_at"` - UpdatedAt string `dynamodbav:"updatedAt,omitempty" json:"updated_at,omitempty"` - CompletedAt string `dynamodbav:"completedAt,omitempty" json:"completed_at,omitempty"` - RunnerSeconds int `dynamodbav:"runnerSeconds,omitempty" json:"runner_seconds,omitempty"` - UploadSeconds int `dynamodbav:"uploadSeconds,omitempty" json:"upload_seconds,omitempty"` - DurationSeconds int `dynamodbav:"durationSeconds,omitempty" json:"duration_seconds,omitempty"` - TTL int64 `dynamodbav:"ttl,omitempty" json:"-"` + ManifestWorkName string `dynamodbav:"manifestWorkName,omitempty" json:"manifest_work_name,omitempty"` + OutputPath string `dynamodbav:"outputPath,omitempty" json:"output_path,omitempty"` + OutputStatus OutputStatus `dynamodbav:"outputStatus,omitempty" json:"output_status,omitempty"` + CreatedAt string `dynamodbav:"createdAt" json:"created_at"` + UpdatedAt string `dynamodbav:"updatedAt,omitempty" json:"updated_at,omitempty"` + CompletedAt string `dynamodbav:"completedAt,omitempty" json:"completed_at,omitempty"` + RunnerSeconds int `dynamodbav:"runnerSeconds,omitempty" json:"runner_seconds,omitempty"` + UploadSeconds int `dynamodbav:"uploadSeconds,omitempty" json:"upload_seconds,omitempty"` + DurationSeconds int `dynamodbav:"durationSeconds,omitempty" json:"duration_seconds,omitempty"` + TTL int64 `dynamodbav:"ttl,omitempty" json:"-"` } // CreateRequest is the JSON body for POST /api/v0/trusted-actions/{action}/run. diff --git a/test/e2e-zoa/zoa_test.go b/test/e2e-zoa/zoa_test.go index 9de5b906..30881122 100644 --- a/test/e2e-zoa/zoa_test.go +++ b/test/e2e-zoa/zoa_test.go @@ -63,10 +63,10 @@ var _ = Describe("ZOA Trusted Actions", Ordered, func() { Expect(resp.StatusCode).To(Equal(http.StatusOK)) var desc struct { - Name string `json:"name"` - Scope string `json:"scope"` - Type string `json:"type"` - Description string `json:"description"` + Name string `json:"name"` + Scope string `json:"scope"` + Type string `json:"type"` + Description string `json:"description"` RequiredFields []string `json:"required_fields"` Params []struct { Name string `json:"name"` From 3beacdc44aeaa82a5efd8caea5975c6a1d5f4e36 Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Wed, 15 Jul 2026 10:34:24 -0500 Subject: [PATCH 2/6] ROSAENG-61801: Internalize codegen runtime and add API types (Option C) Move runtime libraries (registry, featuregate, validation) into internal/codegen/ and API types into api/v2alpha1/. Generator tools (passthrough-gen, marker-scanner) remain as external binaries installed via go install. Add HyperShift API v0.1.76 dependency for passthrough type compilation. Fix pre-existing lint errors in zoa.go, zoa_test.go, and cluster_test.go. Co-Authored-By: Claude Opus 4.6 --- Makefile | 62 +- api/v2alpha1/cluster_types.go | 103 +++ api/v2alpha1/configuration.go | 317 +++++++++ api/v2alpha1/hostedclusterspec.passthrough.go | 206 ++++++ api/v2alpha1/nodepool_types.go | 93 +++ api/v2alpha1/zz_generated.passthrough.go.raw | 208 ++++++ docs/codegen.md | 246 +++---- go.mod | 5 +- go.sum | 6 +- internal/codegen/featuregate/registry.go | 62 ++ internal/codegen/featuregate/types.go | 74 +++ internal/codegen/registry/field_metadata.go | 629 ++++++++++++++++++ internal/codegen/registry/field_metadata.json | 583 ++++++++++++++++ internal/codegen/validation/example_test.go | 140 ++++ .../validation/gated_writemode_test.go | 227 +++++++ internal/codegen/validation/validator.go | 212 ++++++ internal/codegen/validation/validator_test.go | 374 +++++++++++ pkg/handlers/cluster.go | 4 +- pkg/handlers/nodepool.go | 4 +- pkg/handlers/zoa.go | 2 +- pkg/handlers/zoa_test.go | 2 +- pkg/middleware/field_validation.go | 4 +- pkg/middleware/field_validation_test.go | 4 +- test/e2e-cli/cluster_test.go | 4 +- 24 files changed, 3401 insertions(+), 170 deletions(-) create mode 100644 api/v2alpha1/cluster_types.go create mode 100644 api/v2alpha1/configuration.go create mode 100644 api/v2alpha1/hostedclusterspec.passthrough.go create mode 100644 api/v2alpha1/nodepool_types.go create mode 100644 api/v2alpha1/zz_generated.passthrough.go.raw create mode 100644 internal/codegen/featuregate/registry.go create mode 100644 internal/codegen/featuregate/types.go create mode 100644 internal/codegen/registry/field_metadata.go create mode 100644 internal/codegen/registry/field_metadata.json create mode 100644 internal/codegen/validation/example_test.go create mode 100644 internal/codegen/validation/gated_writemode_test.go create mode 100644 internal/codegen/validation/validator.go create mode 100644 internal/codegen/validation/validator_test.go diff --git a/Makefile b/Makefile index e41ca3e8..348bfabf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet codegen-bump codegen-verify +.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet codegen-install-tools codegen-passthrough codegen-registry codegen-verify get-hypershift-version BINARY_NAME := rosa-regional-platform-api IMAGE_REPO ?= quay.io/openshift-online/rosa-regional-platform-api @@ -81,8 +81,10 @@ help: @echo " generate-swagger - Regenerate swagger-ui.html" @echo "" @echo "Codegen Integration:" - @echo " codegen-bump - Update hyperfleet-api-codegen dependency (CODEGEN_VERSION=v0.1.7)" - @echo " codegen-verify - Verify codegen-dependent packages compile" + @echo " codegen-install-tools - Install passthrough-gen and marker-scanner binaries" + @echo " codegen-passthrough - Regenerate passthrough types from HyperShift CRDs" + @echo " codegen-registry - Regenerate field metadata registry from annotated types" + @echo " codegen-verify - Verify codegen and dependent packages compile" @echo "" @echo " all - Run all checks (deps, fmt, vet, lint, test, build)" @@ -345,17 +347,57 @@ verify: git diff --exit-code go.mod go.sum # --- Codegen integration --- - -CODEGEN_VERSION ?= v0.1.7 - -codegen-bump: - go get github.com/cdoan1/hyperfleet-api-codegen@$(CODEGEN_VERSION) - go mod tidy +# API types with markers live in api/v2alpha1/ (checked in). +# Runtime libraries (registry, featuregate, validation) live in internal/codegen/. +# Generator tools are installed as binaries from the codegen repo. + +CODEGEN_TOOLS_MODULE ?= github.com/cdoan1/hyperfleet-api-codegen +CODEGEN_TOOLS_VERSION ?= v0.1.7 +HYPERSHIFT_IMPORT_PATH ?= github.com/openshift/hypershift/api/hypershift/v1beta1 +HYPERSHIFT_TYPES ?= HostedClusterSpec,NodePoolSpec + +codegen-install-tools: + GOBIN=$(PWD)/bin go install $(CODEGEN_TOOLS_MODULE)/cmd/passthrough-gen@$(CODEGEN_TOOLS_VERSION) + GOBIN=$(PWD)/bin go install $(CODEGEN_TOOLS_MODULE)/cmd/marker-scanner@$(CODEGEN_TOOLS_VERSION) + +codegen-passthrough: codegen-install-tools + @echo "Generating passthrough types from $(HYPERSHIFT_IMPORT_PATH)..." + bin/passthrough-gen \ + --import-path=$(HYPERSHIFT_IMPORT_PATH) \ + --types=$(HYPERSHIFT_TYPES) \ + --output-dir=api/v2alpha1 \ + --package=v2alpha1 + @if [ -f api/v2alpha1/zz_generated.passthrough.go ]; then \ + cp api/v2alpha1/zz_generated.passthrough.go api/v2alpha1/hostedclusterspec.passthrough.go; \ + rm api/v2alpha1/zz_generated.passthrough.go; \ + fi + @echo "Done. Edit api/v2alpha1/hostedclusterspec.passthrough.go to curate field markers." + +codegen-registry: codegen-install-tools + @echo "Generating field metadata registry from api/v2alpha1/..." + bin/marker-scanner \ + --input-dirs=api/v2alpha1 \ + --output-file=internal/codegen/registry/field_metadata.go codegen-verify: - @echo "Verifying codegen dependency compiles..." + @echo "Verifying codegen packages compile..." + go build ./api/v2alpha1/... + go build ./internal/codegen/... go build ./pkg/middleware/... go build ./pkg/handlers/... +get-hypershift-version: ## Show current HyperShift version in go.mod + @PSEUDO_VERSION=$$(grep "github.com/openshift/hypershift/api" go.mod | awk '{print $$2}'); \ + COMMIT=$$(echo $$PSEUDO_VERSION | rev | cut -d'-' -f1 | rev); \ + echo "Current HyperShift in go.mod:"; \ + echo " Pseudo-version: $$PSEUDO_VERSION"; \ + echo " Commit: $$COMMIT"; \ + TAG=$$(curl -s https://api.github.com/repos/openshift/hypershift/tags | jq -r ".[] | select(.commit.sha | startswith(\"$$COMMIT\")) | .name" | head -1); \ + if [ -z "$$TAG" ]; then \ + echo " Tag: (no tag found - using commit)"; \ + else \ + echo " Tag: $$TAG"; \ + fi + # All checks all: deps fmt vet lint test build diff --git a/api/v2alpha1/cluster_types.go b/api/v2alpha1/cluster_types.go new file mode 100644 index 00000000..dc26429b --- /dev/null +++ b/api/v2alpha1/cluster_types.go @@ -0,0 +1,103 @@ +package v2alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Cluster represents a HyperFleet managed OpenShift cluster +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +type Cluster struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ClusterSpec `json:"spec"` + Status ClusterStatus `json:"status,omitempty"` +} + +// ClusterSpec defines the desired state of a Cluster +type ClusterSpec struct { + // === HyperFleet Envelope Fields === + // These are HyperFleet-specific fields that wrap the HyperShift cluster + + // DisplayName is a human-readable name for the cluster + // +hyperfleet:write-mode=mutable + // +kubebuilder:validation:MaxLength=256 + DisplayName string `json:"displayName,omitempty"` + + // DeleteProtection prevents accidental deletion when enabled + // +hyperfleet:write-mode=mutable + DeleteProtection *bool `json:"deleteProtection,omitempty"` + + // ExpirationTimestamp marks when this cluster should be automatically deleted + // +hyperfleet:write-mode=mutable + ExpirationTimestamp *metav1.Time `json:"expirationTimestamp,omitempty"` + + // Properties are arbitrary key-value pairs for customer metadata + // +hyperfleet:write-mode=mutable + Properties map[string]string `json:"properties,omitempty"` + + // Tags are customer-defined labels for organizational purposes + // This is a TechPreview feature + // +hyperfleet:write-mode=mutable + // +openshift:enable:FeatureGate=HyperFleetAutoScaling + 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 + AccountID string `json:"accountId,omitempty"` + + // CreatorARN is the AWS ARN of the user who created this cluster (platform-managed, hidden) + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + CreatorARN string `json:"creatorARN,omitempty"` + + // InternalID is an internal platform identifier (platform-managed, hidden) + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + InternalID string `json:"internalId,omitempty"` + + // === HyperShift Passthrough === + // This embeds all upstream HyperShift HostedCluster fields + + // HostedCluster contains the full HyperShift HostedCluster configuration + // All fields are generated from upstream and have safe defaults (hidden + service-set) + // until explicitly reviewed and exposed + // +kubebuilder:validation:Required + HostedCluster HostedClusterSpecPassthrough `json:"hostedCluster"` +} + +// ClusterStatus defines the observed state of a Cluster +type ClusterStatus struct { + // State represents the high-level cluster state + // +kubebuilder:validation:Enum=pending;provisioning;ready;degraded;deleting;failed + State string `json:"state,omitempty"` + + // Conditions represent detailed cluster status + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Version is the observed OpenShift version + Version string `json:"version,omitempty"` + + // APIEndpoint is the cluster API server endpoint + APIEndpoint string `json:"apiEndpoint,omitempty"` + + // ConsoleURL is the web console URL + ConsoleURL string `json:"consoleUrl,omitempty"` + + // ProvisionStartTime is when provisioning began + ProvisionStartTime *metav1.Time `json:"provisionStartTime,omitempty"` + + // ReadyTime is when the cluster became ready + ReadyTime *metav1.Time `json:"readyTime,omitempty"` +} + +// ClusterList contains a list of Clusters +// +kubebuilder:object:root=true +type ClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Cluster `json:"items"` +} diff --git a/api/v2alpha1/configuration.go b/api/v2alpha1/configuration.go new file mode 100644 index 00000000..e9ce2149 --- /dev/null +++ b/api/v2alpha1/configuration.go @@ -0,0 +1,317 @@ +package v2alpha1 + +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/api/v2alpha1/hostedclusterspec.passthrough.go b/api/v2alpha1/hostedclusterspec.passthrough.go new file mode 100644 index 00000000..e7e46890 --- /dev/null +++ b/api/v2alpha1/hostedclusterspec.passthrough.go @@ -0,0 +1,206 @@ +// Code generated by passthrough-gen. DO NOT EDIT. + +package v2alpha1 + +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. + // +k8s:openapi-gen=false + // +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 + // +k8s:openapi-gen=false + // +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/api/v2alpha1/nodepool_types.go b/api/v2alpha1/nodepool_types.go new file mode 100644 index 00000000..7201d91e --- /dev/null +++ b/api/v2alpha1/nodepool_types.go @@ -0,0 +1,93 @@ +package v2alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NodePool represents a HyperFleet managed NodePool for a cluster +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +type NodePool struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec NodePoolSpec `json:"spec"` + Status NodePoolStatus `json:"status,omitempty"` +} + +// NodePoolSpec defines the desired state of a NodePool +type NodePoolSpec struct { + // === HyperFleet Envelope Fields === + + // ClusterRef references the parent Cluster + // +kubebuilder:validation:Required + ClusterRef ClusterReference `json:"clusterRef"` + + // DisplayName is a human-readable name for the node pool + // +hyperfleet:write-mode=mutable + DisplayName string `json:"displayName,omitempty"` + + // AutoRepair enables automatic repair of unhealthy nodes + // +hyperfleet:write-mode=mutable + AutoRepair *bool `json:"autoRepair,omitempty"` + + // Labels to apply to nodes in this pool + // +hyperfleet:write-mode=mutable + Labels map[string]string `json:"labels,omitempty"` + + // AccountID identifies the customer account (platform-managed, hidden) + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + AccountID string `json:"accountId,omitempty"` + + // InternalPoolID is an internal platform identifier (platform-managed, hidden) + // +k8s:openapi-gen=false + // +hyperfleet:write-mode=service-set + InternalPoolID string `json:"internalPoolId,omitempty"` + + // === HyperShift Passthrough === + + // NodePool contains the full HyperShift NodePool configuration + // All fields are generated from upstream and have safe defaults (hidden + service-set) + // +kubebuilder:validation:Required + NodePool NodePoolSpecPassthrough `json:"nodePool"` +} + +// ClusterReference identifies the parent cluster +type ClusterReference struct { + // Name is the name of the Cluster resource + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Namespace is the namespace of the Cluster resource + // If empty, defaults to the same namespace as this NodePool + Namespace string `json:"namespace,omitempty"` +} + +// NodePoolStatus defines the observed state of a NodePool +type NodePoolStatus struct { + // State represents the high-level node pool state + // +kubebuilder:validation:Enum=pending;scaling;ready;degraded;deleting;failed + State string `json:"state,omitempty"` + + // Conditions represent detailed node pool status + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Replicas is the current number of nodes + Replicas int32 `json:"replicas,omitempty"` + + // ReadyReplicas is the number of ready nodes + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + + // AvailableReplicas is the number of available nodes + AvailableReplicas int32 `json:"availableReplicas,omitempty"` +} + +// NodePoolList contains a list of NodePools +// +kubebuilder:object:root=true +type NodePoolList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NodePool `json:"items"` +} diff --git a/api/v2alpha1/zz_generated.passthrough.go.raw b/api/v2alpha1/zz_generated.passthrough.go.raw new file mode 100644 index 00000000..861e987e --- /dev/null +++ b/api/v2alpha1/zz_generated.passthrough.go.raw @@ -0,0 +1,208 @@ +// Code generated by passthrough-gen. DO NOT EDIT. + +package v2alpha1 + + +import ( + hypershiftv1beta1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + configv1 "github.com/openshift/api/config/v1" + 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. + // +k8s:openapi-gen=false + // +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 + // +k8s:openapi-gen=false + // +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/docs/codegen.md b/docs/codegen.md index 16b3d331..f8bebff8 100644 --- a/docs/codegen.md +++ b/docs/codegen.md @@ -1,55 +1,87 @@ # Codegen Integration: hyperfleet-api-codegen -**Codegen repo:** `github.com/cdoan1/hyperfleet-api-codegen` (tag: v0.1.7) +**Codegen repo:** `github.com/cdoan1/hyperfleet-api-codegen` **Jira:** [ROSAENG-61801](https://redhat.atlassian.net/browse/ROSAENG-61801) **Parent:** [ROSAENG-61383](https://redhat.atlassian.net/browse/ROSAENG-61383) -## What was done +## Architecture -Phases 1, 2, and 6 of the [integration spec](https://github.com/cdoan1/hyperfleet-api-codegen/blob/main/docs/integration-rosa-hyperfleet-api.md) are complete. The gateway now imports the codegen repo as a Go library and enforces write-mode (mutable/immutable/service-set) and feature-gate validation on cluster and nodepool mutations. +| What | Location | Dependencies | +|------|----------|-------------| +| **API types** (annotated with markers) | `api/v2alpha1/` in this repo | `openshift/hypershift/api` (v0.1.76), `k8s.io/apimachinery` | +| **Runtime libraries** (registry, featuregate, validation) | `internal/codegen/` in this repo | None (stdlib only) | +| **Generator tools** (passthrough-gen, marker-scanner) | Codegen repo, installed as binaries via `go install` | Heavy deps stay in tool binaries, not in go.mod | -### Phase 1: Module dependency +### How it works -Added `github.com/cdoan1/hyperfleet-api-codegen@v0.1.7` as a direct dependency. This required upgrading: +1. **`passthrough-gen`** reads HyperShift CRD types (via go.mod import path) and generates initial passthrough Go structs in `api/v2alpha1/` +2. **Developer** annotates those structs with `+hyperfleet:write-mode=ServiceSet`, `+openshift:enable:FeatureGate`, etc. +3. **`marker-scanner`** reads the annotated files and generates `internal/codegen/registry/field_metadata.go` +4. Annotated types and generated registry are **checked in** +5. At runtime, `pkg/middleware/field_validation.go` uses the registry to enforce write-mode and feature-gate rules -- Go: 1.25.4 → 1.26.0 -- k8s.io/apimachinery: v0.34.3 → v0.36.0 -- k8s.io/api: v0.34.3 → v0.36.0 -- k8s.io/client-go: v0.34.3 → v0.36.0 +### Directory structure -The k8s.io/client-go upgrade was required because maestro's client-go v0.34.3 references packages removed in k8s.io/api v0.36.0. +``` +api/v2alpha1/ + cluster_types.go # hand-written cluster/status types + configuration.go # hand-written configuration types + hostedclusterspec.passthrough.go # generated by passthrough-gen, then annotated with markers + nodepool_types.go # hand-written nodepool/status types + +internal/codegen/ + registry/ + field_metadata.go # generated by marker-scanner from api/v2alpha1/ annotations + featuregate/ + types.go # FeatureSet, FeatureStage types and constants + registry.go # HyperFleetFeatureGates map, IsGateEnabled() + validation/ + validator.go # Validator struct, Validate(), ValidationErrors + validator_test.go + example_test.go + gated_writemode_test.go +``` -**Files changed:** `go.mod`, `go.sum` +## Makefile targets -### Phase 2: Validation middleware +```bash +make codegen-install-tools # Install passthrough-gen and marker-scanner into bin/ +make codegen-passthrough # Regenerate passthrough types from HyperShift CRDs +make codegen-registry # Regenerate field_metadata.go from annotated api/v2alpha1/ types +make codegen-verify # Verify all codegen-dependent packages compile +``` -#### pkg/middleware/field_validation.go (new) +Variables: +- `CODEGEN_TOOLS_VERSION` — codegen repo tag for tool binaries (default: `v0.1.7`) +- `HYPERSHIFT_IMPORT_PATH` — HyperShift types import path (default: `github.com/openshift/hypershift/api/hypershift/v1beta1`) +- `HYPERSHIFT_TYPES` — types to generate passthroughs for (default: `HostedClusterSpec,NodePoolSpec`) -Wraps the codegen's `validation.Validator`. Provides two methods: +## What was done -- `ValidateCreate(spec, featureSet, enabledGates)` — validates a create request -- `ValidateUpdate(spec, existingSpec, featureSet, enabledGates)` — validates an update request +### Phases 1 + Option C: Runtime libraries internalized + API types -Key design: the codegen registry uses dotted paths with `spec.` prefix (e.g., `spec.displayName`, `spec.accountId`). The request body spec is a `map[string]interface{}` with keys like `displayName`. The `flattenWithPrefix("spec", spec)` helper recursively flattens nested maps into dotted-path keys to match the registry format. +- Copied runtime libraries (registry, featuregate, validation) from codegen repo into `internal/codegen/` +- Copied API types from codegen repo's `api/v1alpha1/` into `api/v2alpha1/` (package renamed) +- Added `openshift/hypershift/api` v0.1.76 to go.mod (required for passthrough type compilation) +- External `github.com/cdoan1/hyperfleet-api-codegen` module is not in go.mod — tools are installed as binaries -```go -fv := middleware.NewFieldValidator() -err := fv.ValidateCreate(req.Spec, featuregate.Default, nil) -``` +### Phase 2: Validation middleware -Imports from codegen: -- `github.com/cdoan1/hyperfleet-api-codegen/pkg/validation` -- `github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate` +#### pkg/middleware/field_validation.go -#### pkg/handlers/cluster.go (modified) +Wraps `validation.Validator` from `internal/codegen/validation`. Provides: -- Added `fieldValidator *middleware.FieldValidator` to `ClusterHandler` struct -- Updated `NewClusterHandler` signature: added `*middleware.FieldValidator` parameter -- `Create`: after JSON decode and basic field checks, calls `ValidateCreate`. Returns 422 on failure. -- `Update`: fetches existing cluster via `hyperfleetClient.GetCluster()`, then calls `ValidateUpdate` with both new and existing specs. Returns 422 on failure. -- Added `writeValidationError` helper returning structured 422 response +- `ValidateCreate(spec, featureSet, enabledGates)` — validates a create request +- `ValidateUpdate(spec, existingSpec, featureSet, enabledGates)` — validates an update request + +The `flattenWithPrefix("spec", spec)` helper recursively flattens nested `map[string]interface{}` into dotted-path keys to match the registry format (`spec.displayName`, `spec.hostedCluster.channel`, etc.). -Validation is nil-safe — if `fieldValidator` is nil, validation is skipped. This preserves backward compatibility for tests that pass `nil`. +#### Handler integration + +- `ClusterHandler` and `NodePoolHandler` both accept `*middleware.FieldValidator` +- Validation runs after JSON decode, before business logic +- Returns 422 with field-level details on failure +- Nil-safe — if `fieldValidator` is nil, validation is skipped 422 response format: @@ -64,142 +96,68 @@ Validation is nil-safe — if `fieldValidator` is nil, validation is skipped. Th } ``` -#### pkg/handlers/nodepool.go (modified) - -Same pattern as cluster handler: -- Added `fieldValidator` field and updated `NewNodePoolHandler` signature -- Validation calls in `Create` and `Update` -- Added `nodePoolSpecToMap` helper to convert `*types.NodePoolSpec` struct to `map[string]interface{}` for the validator - -#### pkg/server/server.go (modified) - -Creates `middleware.NewFieldValidator()` once and passes it to both `NewClusterHandler` and `NewNodePoolHandler`. - -#### pkg/middleware/field_validation_test.go (new, 12 tests) - -| Test | What it validates | -|------|-------------------| -| MutableFieldAllowed | `displayName` accepted on create | -| ServiceSetFieldRejected | `accountId` rejected on create | -| MultipleServiceSetFieldsRejected | `accountId`, `creatorARN`, `internalId` all rejected | -| UpdateMutableAllowed | `displayName` change accepted on update | -| UpdateServiceSetRejected | `accountId` rejected on update | -| FeatureGatedFieldRejected | `tags` rejected with Default feature set | -| FeatureGatedFieldAllowedWithTechPreview | `tags` accepted with TechPreviewNoUpgrade | -| UnknownFieldAllowed | Fields not in registry pass through | -| NestedServiceSetFieldRejected | `spec.hostedCluster.pullSecret` rejected | -| MixedFields | Only service-set fields error, mutable fields pass | -| FlattenWithPrefix | Verifies dotted-path key generation | -| FlattenWithPrefix_EmptyMap | Empty input produces empty output | - -#### pkg/handlers/cluster_test.go (modified) - -- All `NewClusterHandler` calls updated from 3 to 4 args (added `nil` for fieldValidator) -- Added `TestClusterHandler_Create_ValidationRejectsServiceSetField` — sends `accountId` in spec, expects 422 -- Added `TestClusterHandler_Create_ValidationAllowsMutableFields` — sends `displayName` only, expects 201 - ### Phase 6: Makefile targets -Added to `Makefile`: - -```makefile -CODEGEN_VERSION ?= v0.1.7 - -codegen-bump: - go get github.com/cdoan1/hyperfleet-api-codegen@$(CODEGEN_VERSION) - go mod tidy - -codegen-verify: - @echo "Verifying codegen dependency compiles..." - go build ./pkg/middleware/... - go build ./pkg/handlers/... -``` - -Usage: - -```bash -make codegen-bump CODEGEN_VERSION=v0.1.8 # upgrade codegen dep -make codegen-verify # verify codegen packages compile -``` +See [Makefile targets](#makefile-targets) section above. ## What remains | Phase | Description | Effort | Notes | |-------|-------------|--------|-------| -| 3 | Replace hardcoded service-set injection with codegen conversion functions | Small | Replace manual `req.Spec["cloudUrl"] = ...` in cluster.go with `conversion.UnprojectCluster()` | -| 4 | Migrate to typed specs | Large | Replace `map[string]interface{}` with codegen REST types (`rest.ClusterSpec`). Touches every handler, client, and test that accesses spec fields. | -| 5 | OpenAPI spec alignment | Medium | Replace freeform `spec: object` in openapi.yaml with schemas generated from codegen | - -Recommended order: 3 → 5 → 4. Phases 2 and 3 work with existing `map[string]interface{}` types. Phase 4 is the largest change and can be deferred. - -## How to recreate this work +| 3 | Replace hardcoded service-set injection with codegen conversion functions | Small | Replace manual `req.Spec["cloudUrl"] = ...` with conversion functions | +| 4 | Migrate to typed specs | Large | Replace `map[string]interface{}` with `api/v2alpha1` types in handlers | +| 5 | OpenAPI spec alignment | Medium | Replace freeform `spec: object` in openapi.yaml with generated schemas | -Given a clean `main` branch, an AI agent can reproduce Phases 1, 2, and 6 with these instructions: +## Workflows -1. **Add the codegen dependency:** - ```bash - go get github.com/cdoan1/hyperfleet-api-codegen@v0.1.7 - ``` - If `go mod tidy` fails on k8s dependency conflicts, upgrade k8s.io/client-go to match the k8s.io/apimachinery version pulled in by the codegen: - ```bash - go get k8s.io/client-go@v0.36.0 - go mod tidy - ``` +### HyperShift version bump -2. **Create `pkg/middleware/field_validation.go`** — a `FieldValidator` struct wrapping `validation.NewValidator()` with `ValidateCreate` and `ValidateUpdate` methods. Include `flattenWithPrefix` to convert `map[string]interface{}` keys to dotted `spec.*` paths matching the codegen registry. +When upstream HyperShift releases a new version: -3. **Wire into handlers** — add `fieldValidator *middleware.FieldValidator` to `ClusterHandler` and `NodePoolHandler` structs. Call `ValidateCreate`/`ValidateUpdate` after JSON decode, before business logic. Return 422 with field-level details on failure. Use nil-checks so tests can pass `nil` to skip validation. - -4. **Update `pkg/server/server.go`** — create `middleware.NewFieldValidator()` and pass to both handler constructors. - -5. **Add tests** — middleware unit tests covering mutable/service-set/immutable/feature-gated/unknown/nested fields. Handler integration tests for 422 on service-set fields and 201 on valid fields. - -6. **Add Makefile targets** — `codegen-bump` and `codegen-verify`. - -7. **Verify** — `go build ./...`, `make test`, `make lint` should all pass. +```bash +# Update HyperShift dependency +go get github.com/openshift/hypershift/api@ +go mod tidy -## Pending design decision: where should the codegen code live? +# Regenerate passthrough types (picks up new/changed fields) +make codegen-passthrough -**Status:** Needs team input +# Review changes to hostedclusterspec.passthrough.go +# Add markers to any new fields, then regenerate the registry +make codegen-registry -The codegen repo (`github.com/cdoan1/hyperfleet-api-codegen`) is currently only used by this API project. Should we merge it into this repo, keep it separate, or do a partial merge? +# Verify +make codegen-verify +make test +``` -### The two halves of the codegen repo +### Adding a new field marker -The codegen repo contains two distinct categories of code: +When annotating a field in `api/v2alpha1/hostedclusterspec.passthrough.go`: -| Category | Packages | Dependencies | Used when | -|----------|----------|-------------|-----------| -| **Runtime libraries** | `pkg/registry/`, `pkg/validation/`, `pkg/featuregate/`, `pkg/conversion/` | Lightweight (k8s.io/apimachinery only) | Every API request — imported by handlers and middleware | -| **Generator tools** | `cmd/passthrough-gen`, `cmd/marker-scanner`, `cmd/openapi-gen`, `cmd/conversion-gen`, `cmd/crd-variants`, `cmd/verify-configuration` | Heavy (openshift/hypershift/api, AST parsing, controller-runtime, code-generator) | Only when HyperShift CRDs change — run offline to regenerate types | +```bash +# Edit the file, add/change markers like: +# +hyperfleet:write-mode=ServiceSet +# +openshift:enable:FeatureGates=HyperFleetAutoScaling -### Options +# Regenerate the registry +make codegen-registry -| Option | Description | Pros | Cons | -|--------|-------------|------|------| -| **A. Keep separate** (current) | Codegen stays in its own repo, imported as `go get` dependency | Clean separation; heavy generator deps stay out of gateway go.mod; independent release cycle | Two repos to maintain; version coordination on every bump; k8s dependency alignment issues (already hit in Phase 1) | -| **B. Merge everything** | Move entire codegen repo into this repo (e.g., `pkg/codegen/` or `internal/codegen/`) | Single repo; no version coordination; easier cross-cutting changes | Gateway go.mod inherits all generator deps (HyperShift API, controller-runtime, etc.) even though they're only needed at generation time; heavier builds; worse k8s version conflicts | -| **C. Partial merge** (recommended) | Move runtime libraries into this repo (e.g., `internal/codegen/registry/`, `internal/codegen/validation/`). Keep generator tools in the separate repo or as a Go sub-module. | Runtime code is local — no external dep for request-path code; generator deps stay isolated; simpler day-to-day development | Still two places to look for codegen-related code; generator tool changes still require a separate workflow | -| **D. Go workspace** | Use a Go workspace (`go.work`) with both repos checked out side-by-side | Develop across both repos without publishing versions; each repo keeps its own go.mod | Requires both repos checked out locally; CI needs workspace setup; go.work files shouldn't be committed | +# Verify +make codegen-verify +make test +``` -### Key considerations +## How to recreate from scratch -- **Dependency weight:** The generator tools pull in `openshift/hypershift/api`, `controller-runtime`, and k8s code-generator packages. Merging these into the gateway caused k8s.io/api version conflicts during Phase 1 (removed packages in v0.36.0 broke maestro's client-go v0.34.3). This problem gets worse if generators and API share a go.mod. -- **Change frequency:** Generator tools only run when upstream HyperShift CRDs change. Runtime libraries change when validation rules or field metadata evolve. The API server changes frequently. Different cadences favor separation. -- **Team workflow:** A single repo is simpler for code review and CI. Two repos mean PRs that span both are harder to coordinate. -- **Future consumers:** If another project ever needs the codegen output, a separate repo is easier to share. If this API is the only consumer, the separate repo is overhead. +1. **Create `api/v2alpha1/`** — run `make codegen-passthrough` to generate passthrough types from HyperShift, then add `cluster_types.go`, `configuration.go`, `nodepool_types.go` for non-passthrough types. Annotate fields with `+hyperfleet:write-mode=...` markers. -### Decision needed +2. **Create `internal/codegen/`** — run `make codegen-registry` to generate `field_metadata.go`. Copy `featuregate/types.go`, `featuregate/registry.go`, and `validation/validator.go` from the codegen repo, fixing import paths to `internal/codegen/`. -Team should weigh in on which option to pursue before Phase 3 work begins, since Phase 3 (conversion functions) and Phase 4 (typed specs) will significantly increase the coupling between the two codebases. +3. **Create `pkg/middleware/field_validation.go`** — wraps `validation.NewValidator()` with `ValidateCreate` and `ValidateUpdate` methods. Include `flattenWithPrefix` helper. -## Codegen version bump workflow +4. **Wire into handlers** — add `fieldValidator` to `ClusterHandler` and `NodePoolHandler`. Call validation after JSON decode, before business logic. -When the codegen repo releases a new version: +5. **Update `pkg/server/server.go`** — create `NewFieldValidator()` and pass to both handler constructors. -```bash -make codegen-bump CODEGEN_VERSION=v0.1.8 -go build ./... # compile errors surface breaking changes -make test # run full test suite -make codegen-verify # verify codegen-dependent packages -``` +6. **Verify** — `go build ./...`, `make test`, `make lint`. diff --git a/go.mod b/go.mod index c758f6d0..4c1874dc 100644 --- a/go.mod +++ b/go.mod @@ -10,16 +10,18 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.55.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2 github.com/aws/aws-sdk-go-v2/service/verifiedpermissions v1.24.0 - github.com/cdoan1/hyperfleet-api-codegen v0.1.7 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/openshift-online/maestro v0.0.0-20260203054609-18a68bb9f147 + github.com/openshift/api v0.0.0-20260416105050-3c6b218b8a80 + github.com/openshift/hypershift/api v0.0.0-20260512154912-4341d0cf1833 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.36.0 k8s.io/apimachinery v0.36.0 open-cluster-management.io/api v1.2.0 open-cluster-management.io/sdk-go v1.1.1-0.20260128013609-7a2e40f02c1d @@ -109,7 +111,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/api v0.36.0 // indirect k8s.io/client-go v0.36.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 // indirect diff --git a/go.sum b/go.sum index 9c381ea4..bfc55d77 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= -github.com/cdoan1/hyperfleet-api-codegen v0.1.7 h1:yf+3cdr0pPLDf55nA+ahlkaAoYFliX9C85kL4p+c5Vo= -github.com/cdoan1/hyperfleet-api-codegen v0.1.7/go.mod h1:bRno7UADIb8bHZyW84xUz+qBhC5FiGSQyGc9sZ3GsmY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= @@ -174,6 +172,10 @@ github.com/openshift-online/maestro v0.0.0-20260203054609-18a68bb9f147 h1:1MBPzA github.com/openshift-online/maestro v0.0.0-20260203054609-18a68bb9f147/go.mod h1:cyeif610uObNrbcyn5s1fZg7OWseVjaMAqgrEDA2Aec= github.com/openshift-online/ocm-sdk-go v0.1.493 h1:+889zmbwN0guA8LFRr5WHpH2+VJNq8+r0fvrXY+x/6E= github.com/openshift-online/ocm-sdk-go v0.1.493/go.mod h1:ThqKHtIyvTvDA5AxGFZph80sllVr63lZ+sb4qQP57+o= +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-20260512154912-4341d0cf1833 h1:ETbnPudwT+OZRiVQsvWloLZddK61IROp3nXWhXCwfWw= +github.com/openshift/hypershift/api v0.0.0-20260512154912-4341d0cf1833/go.mod h1:x7coah07adUwvKkr7pG9+qqs4FNb4nU+qQVhgq9v664= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/codegen/featuregate/registry.go b/internal/codegen/featuregate/registry.go new file mode 100644 index 00000000..1f65122b --- /dev/null +++ b/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/internal/codegen/featuregate/types.go b/internal/codegen/featuregate/types.go new file mode 100644 index 00000000..ec8e7454 --- /dev/null +++ b/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/internal/codegen/registry/field_metadata.go b/internal/codegen/registry/field_metadata.go new file mode 100644 index 00000000..da8fb233 --- /dev/null +++ b/internal/codegen/registry/field_metadata.go @@ -0,0 +1,629 @@ +// 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{ + "allowedUnsafeSysctls": { + FieldPath: "allowedUnsafeSysctls", + WriteMode: ServiceSet, + Hidden: true, + }, + "apiServer": { + FieldPath: "apiServer", + WriteMode: ServiceSet, + Hidden: true, + }, + "authentication": { + FieldPath: "authentication", + WriteMode: ServiceSet, + Hidden: true, + }, + "containerLogMaxFiles": { + FieldPath: "containerLogMaxFiles", + WriteMode: Mutable, + }, + "containerLogMaxSize": { + FieldPath: "containerLogMaxSize", + WriteMode: Mutable, + }, + "cpuManagerPolicy": { + FieldPath: "cpuManagerPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "cpuManagerPolicyOptions": { + FieldPath: "cpuManagerPolicyOptions", + WriteMode: ServiceSet, + Hidden: true, + }, + "cpuManagerReconcilePeriod": { + FieldPath: "cpuManagerReconcilePeriod", + WriteMode: ServiceSet, + Hidden: true, + }, + "evictionHard": { + FieldPath: "evictionHard", + WriteMode: ServiceSet, + Hidden: true, + }, + "evictionSoft": { + FieldPath: "evictionSoft", + WriteMode: ServiceSet, + Hidden: true, + }, + "evictionSoftGracePeriod": { + FieldPath: "evictionSoftGracePeriod", + WriteMode: ServiceSet, + Hidden: true, + }, + "featureGate": { + FieldPath: "featureGate", + WriteMode: ServiceSet, + Hidden: true, + }, + "image": { + FieldPath: "image", + WriteMode: ServiceSet, + Hidden: true, + }, + "imageGCHighThresholdPercent": { + FieldPath: "imageGCHighThresholdPercent", + WriteMode: Mutable, + }, + "imageGCLowThresholdPercent": { + FieldPath: "imageGCLowThresholdPercent", + WriteMode: Mutable, + }, + "imageMinimumGCAge": { + FieldPath: "imageMinimumGCAge", + WriteMode: Mutable, + }, + "ingress": { + FieldPath: "ingress", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubeReserved": { + FieldPath: "kubeReserved", + WriteMode: Immutable, + }, + "kubelet": { + FieldPath: "kubelet", + WriteMode: ServiceSet, + }, + "kubelet.allowedUnsafeSysctls": { + FieldPath: "kubelet.allowedUnsafeSysctls", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.containerLogMaxFiles": { + FieldPath: "kubelet.containerLogMaxFiles", + WriteMode: Mutable, + }, + "kubelet.containerLogMaxSize": { + FieldPath: "kubelet.containerLogMaxSize", + WriteMode: Mutable, + }, + "kubelet.cpuManagerPolicy": { + FieldPath: "kubelet.cpuManagerPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.cpuManagerPolicyOptions": { + FieldPath: "kubelet.cpuManagerPolicyOptions", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.cpuManagerReconcilePeriod": { + FieldPath: "kubelet.cpuManagerReconcilePeriod", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.evictionHard": { + FieldPath: "kubelet.evictionHard", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.evictionSoft": { + FieldPath: "kubelet.evictionSoft", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.evictionSoftGracePeriod": { + FieldPath: "kubelet.evictionSoftGracePeriod", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.imageGCHighThresholdPercent": { + FieldPath: "kubelet.imageGCHighThresholdPercent", + WriteMode: Mutable, + }, + "kubelet.imageGCLowThresholdPercent": { + FieldPath: "kubelet.imageGCLowThresholdPercent", + WriteMode: Mutable, + }, + "kubelet.imageMinimumGCAge": { + FieldPath: "kubelet.imageMinimumGCAge", + WriteMode: Mutable, + }, + "kubelet.kubeReserved": { + FieldPath: "kubelet.kubeReserved", + WriteMode: Immutable, + }, + "kubelet.maxPods": { + FieldPath: "kubelet.maxPods", + WriteMode: Mutable, + }, + "kubelet.memoryThrottlingFactor": { + FieldPath: "kubelet.memoryThrottlingFactor", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.podPidsLimit": { + FieldPath: "kubelet.podPidsLimit", + WriteMode: Mutable, + }, + "kubelet.registryBurst": { + FieldPath: "kubelet.registryBurst", + WriteMode: Mutable, + FeatureGate: "HyperFleetKubeletAdvanced", + }, + "kubelet.registryPullQPS": { + FieldPath: "kubelet.registryPullQPS", + WriteMode: Mutable, + FeatureGate: "HyperFleetKubeletAdvanced", + }, + "kubelet.serializeImagePulls": { + FieldPath: "kubelet.serializeImagePulls", + WriteMode: Mutable, + FeatureGate: "HyperFleetKubeletAdvanced", + }, + "kubelet.streamingConnectionIdleTimeout": { + FieldPath: "kubelet.streamingConnectionIdleTimeout", + WriteMode: Mutable, + }, + "kubelet.systemReserved": { + FieldPath: "kubelet.systemReserved", + WriteMode: Immutable, + }, + "kubelet.topologyManagerPolicy": { + FieldPath: "kubelet.topologyManagerPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "kubelet.topologyManagerScope": { + FieldPath: "kubelet.topologyManagerScope", + WriteMode: ServiceSet, + Hidden: true, + }, + "machineConfig": { + FieldPath: "machineConfig", + WriteMode: ServiceSet, + }, + "machineConfig.allowedKernelArguments": { + FieldPath: "machineConfig.allowedKernelArguments", + WriteMode: Immutable, + FeatureGate: "HyperFleetMachineConfig", + }, + "machineConfig.extensions": { + FieldPath: "machineConfig.extensions", + WriteMode: ServiceSet, + Hidden: true, + }, + "machineConfig.files": { + FieldPath: "machineConfig.files", + WriteMode: ServiceSet, + Hidden: true, + }, + "machineConfig.fips": { + FieldPath: "machineConfig.fips", + WriteMode: Immutable, + }, + "machineConfig.kernelArguments": { + FieldPath: "machineConfig.kernelArguments", + WriteMode: ServiceSet, + Hidden: true, + }, + "machineConfig.kernelType": { + FieldPath: "machineConfig.kernelType", + WriteMode: ServiceSet, + Hidden: true, + }, + "machineConfig.systemdUnits": { + FieldPath: "machineConfig.systemdUnits", + WriteMode: ServiceSet, + Hidden: true, + }, + "maxPods": { + FieldPath: "maxPods", + WriteMode: Mutable, + }, + "memoryThrottlingFactor": { + FieldPath: "memoryThrottlingFactor", + WriteMode: ServiceSet, + Hidden: true, + }, + "network": { + FieldPath: "network", + WriteMode: ServiceSet, + Hidden: true, + }, + "oauth": { + FieldPath: "oauth", + WriteMode: ServiceSet, + Hidden: true, + }, + "podPidsLimit": { + FieldPath: "podPidsLimit", + WriteMode: Mutable, + }, + "proxy": { + FieldPath: "proxy", + WriteMode: ServiceSet, + Hidden: true, + }, + "registryBurst": { + FieldPath: "registryBurst", + WriteMode: Mutable, + FeatureGate: "HyperFleetKubeletAdvanced", + }, + "registryPullQPS": { + FieldPath: "registryPullQPS", + WriteMode: Mutable, + FeatureGate: "HyperFleetKubeletAdvanced", + }, + "scheduler": { + FieldPath: "scheduler", + WriteMode: ServiceSet, + Hidden: true, + }, + "serializeImagePulls": { + FieldPath: "serializeImagePulls", + WriteMode: Mutable, + FeatureGate: "HyperFleetKubeletAdvanced", + }, + "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, + Hidden: true, + }, + "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, + Hidden: true, + }, + "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", + }, + "streamingConnectionIdleTimeout": { + FieldPath: "streamingConnectionIdleTimeout", + WriteMode: Mutable, + }, + "systemReserved": { + FieldPath: "systemReserved", + WriteMode: Immutable, + }, + "topologyManagerPolicy": { + FieldPath: "topologyManagerPolicy", + WriteMode: ServiceSet, + Hidden: true, + }, + "topologyManagerScope": { + FieldPath: "topologyManagerScope", + WriteMode: ServiceSet, + Hidden: true, + }, +} diff --git a/internal/codegen/registry/field_metadata.json b/internal/codegen/registry/field_metadata.json new file mode 100644 index 00000000..357a1ed0 --- /dev/null +++ b/internal/codegen/registry/field_metadata.json @@ -0,0 +1,583 @@ +[ + { + "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", + "hidden": true + }, + { + "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", + "hidden": true + }, + { + "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" + }, + { + "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/internal/codegen/validation/example_test.go b/internal/codegen/validation/example_test.go new file mode 100644 index 00000000..5d2b1a62 --- /dev/null +++ b/internal/codegen/validation/example_test.go @@ -0,0 +1,140 @@ +package validation_test + +import ( + "fmt" + "log" + + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/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/internal/codegen/validation/gated_writemode_test.go b/internal/codegen/validation/gated_writemode_test.go new file mode 100644 index 00000000..42c7f0d6 --- /dev/null +++ b/internal/codegen/validation/gated_writemode_test.go @@ -0,0 +1,227 @@ +package validation + +import ( + "testing" + + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/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/internal/codegen/validation/validator.go b/internal/codegen/validation/validator.go new file mode 100644 index 00000000..6c424da6 --- /dev/null +++ b/internal/codegen/validation/validator.go @@ -0,0 +1,212 @@ +package validation + +import ( + "fmt" + "strings" + + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/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) + for _, override := range meta.FeatureGateAwareWriteModes { + if override.FeatureGate != "" && req.IsFeatureGateEnabled(override.FeatureGate) { + effectiveMode = override.WriteMode + break // First specific match wins + } + } + + // If no specific match, check for default override (empty gate) + if effectiveMode == meta.WriteMode { // Still using base mode + 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 { + // Check if the field is actually being changed + if req.ExistingFields != nil { + _, existsInOld := req.ExistingFields[fieldPath] + if existsInOld { + return &ValidationError{ + FieldPath: fieldPath, + Reason: "field is immutable and cannot be changed after creation", + } + } + } + // If field doesn't exist in old resource, this is adding a new field on update + // which is allowed for immutable fields (they can be set once) + } + // On create, immutable fields can be set + 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/internal/codegen/validation/validator_test.go b/internal/codegen/validation/validator_test.go new file mode 100644 index 00000000..1094689a --- /dev/null +++ b/internal/codegen/validation/validator_test.go @@ -0,0 +1,374 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/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 (field exists) - 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_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/pkg/handlers/cluster.go b/pkg/handlers/cluster.go index f80366fc..136c7cf3 100644 --- a/pkg/handlers/cluster.go +++ b/pkg/handlers/cluster.go @@ -13,8 +13,8 @@ import ( "github.com/openshift/rosa-regional-platform-api/pkg/middleware" "github.com/openshift/rosa-regional-platform-api/pkg/types" - "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" - "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/validation" ) // ClusterHandler handles cluster-related HTTP requests diff --git a/pkg/handlers/nodepool.go b/pkg/handlers/nodepool.go index 80667e1f..ab139f99 100644 --- a/pkg/handlers/nodepool.go +++ b/pkg/handlers/nodepool.go @@ -11,8 +11,8 @@ import ( "github.com/openshift/rosa-regional-platform-api/pkg/middleware" "github.com/openshift/rosa-regional-platform-api/pkg/types" - "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" - "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/validation" ) // NodePoolHandler handles nodepool-related HTTP requests diff --git a/pkg/handlers/zoa.go b/pkg/handlers/zoa.go index ad288d93..1c3d6926 100644 --- a/pkg/handlers/zoa.go +++ b/pkg/handlers/zoa.go @@ -476,7 +476,7 @@ func (h *ZoaHandler) fetchS3Content(ctx context.Context, s3URI string) ([]byte, if err != nil { return nil, err } - defer result.Body.Close() + defer func() { _ = result.Body.Close() }() return io.ReadAll(result.Body) } diff --git a/pkg/handlers/zoa_test.go b/pkg/handlers/zoa_test.go index 5f78ce55..cedcf447 100644 --- a/pkg/handlers/zoa_test.go +++ b/pkg/handlers/zoa_test.go @@ -292,7 +292,7 @@ func TestZoaHandler_Get_Found(t *testing.T) { var resp zoa.ExecutionResponse err := json.NewDecoder(rr.Body).Decode(&resp) require.NoError(t, err) - assert.Equal(t, "exec-123", resp.Execution.ExecutionID) + assert.Equal(t, "exec-123", resp.ExecutionID) assert.NotNil(t, resp.Output) } diff --git a/pkg/middleware/field_validation.go b/pkg/middleware/field_validation.go index 5e234612..633ec3c2 100644 --- a/pkg/middleware/field_validation.go +++ b/pkg/middleware/field_validation.go @@ -1,8 +1,8 @@ package middleware import ( - "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" - "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/validation" ) type FieldValidator struct { diff --git a/pkg/middleware/field_validation_test.go b/pkg/middleware/field_validation_test.go index e1fe73a4..7bb0f138 100644 --- a/pkg/middleware/field_validation_test.go +++ b/pkg/middleware/field_validation_test.go @@ -3,8 +3,8 @@ package middleware import ( "testing" - "github.com/cdoan1/hyperfleet-api-codegen/pkg/featuregate" - "github.com/cdoan1/hyperfleet-api-codegen/pkg/validation" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/validation" ) func TestFieldValidator_ValidateCreate_MutableFieldAllowed(t *testing.T) { diff --git a/test/e2e-cli/cluster_test.go b/test/e2e-cli/cluster_test.go index a9e6bd05..036a4bd3 100644 --- a/test/e2e-cli/cluster_test.go +++ b/test/e2e-cli/cluster_test.go @@ -67,8 +67,8 @@ func recordTiming(phase string) func() { if err != nil { return } - defer f.Close() - fmt.Fprintln(f, record) + defer func() { _ = f.Close() }() + _, _ = fmt.Fprintln(f, record) } } From f4c941bbc2596c875d796c20ca5e89c71ab3f931 Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Wed, 15 Jul 2026 10:58:22 -0500 Subject: [PATCH 3/6] ROSAENG-61801: Replace hardcoded service-set injection with conversion functions (Phase 3) Extract cloudUrl, placement, and creatorARN injection from ClusterHandler into internal/codegen/conversion package. Handler now calls InjectClusterServiceSet() and RewriteCloudURLWithID() instead of setting spec map keys directly. Co-Authored-By: Claude Opus 4.6 --- internal/codegen/conversion/cluster.go | 30 +++++++++++++++++++++++++ pkg/handlers/cluster.go | 31 +++++++++++--------------- 2 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 internal/codegen/conversion/cluster.go diff --git a/internal/codegen/conversion/cluster.go b/internal/codegen/conversion/cluster.go new file mode 100644 index 00000000..643ed3d9 --- /dev/null +++ b/internal/codegen/conversion/cluster.go @@ -0,0 +1,30 @@ +package conversion + +// ClusterServiceSetFields holds platform-injected values for cluster creation. +type ClusterServiceSetFields struct { + CloudURL string + Placement string + CreatorARN string +} + +// InjectClusterServiceSet merges service-set fields into a cluster spec map. +// Only non-empty values are injected. Placement is only set if not already +// present in the spec (allowing client-provided values to take precedence). +func InjectClusterServiceSet(spec map[string]interface{}, ssf ClusterServiceSetFields) { + if ssf.CloudURL != "" { + spec["cloudUrl"] = ssf.CloudURL + } + if ssf.Placement != "" { + if spec["placement"] == nil || spec["placement"] == "" { + spec["placement"] = ssf.Placement + } + } + if ssf.CreatorARN != "" { + spec["creatorARN"] = ssf.CreatorARN + } +} + +// RewriteCloudURLWithID sets cloudUrl to baseURL/clusterID in a response spec. +func RewriteCloudURLWithID(spec map[string]interface{}, baseURL, clusterID string) { + spec["cloudUrl"] = baseURL + "/" + clusterID +} diff --git a/pkg/handlers/cluster.go b/pkg/handlers/cluster.go index 136c7cf3..29c6ee3b 100644 --- a/pkg/handlers/cluster.go +++ b/pkg/handlers/cluster.go @@ -2,7 +2,6 @@ package handlers import ( "encoding/json" - "fmt" "log/slog" "net/http" "strconv" @@ -13,6 +12,7 @@ import ( "github.com/openshift/rosa-regional-platform-api/pkg/middleware" "github.com/openshift/rosa-regional-platform-api/pkg/types" + "github.com/openshift/rosa-regional-platform-api/internal/codegen/conversion" "github.com/openshift/rosa-regional-platform-api/internal/codegen/featuregate" "github.com/openshift/rosa-regional-platform-api/internal/codegen/validation" ) @@ -126,24 +126,19 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { return } - // Add cloudUrl (CloudFront URL only) to the spec before creating the cluster - req.Spec["cloudUrl"] = cloudfrontURL - - // Auto-populate placement from management cluster if not provided by the client - if req.Spec["placement"] == nil || req.Spec["placement"] == "" { - placementName := managementClusters.Items[0].Name - if placementName == "" { - h.logger.Error("management cluster has no name for placement", "cluster_id", managementClusters.Items[0].ID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-CREATE-007", "Management cluster name not available for placement") - return - } - req.Spec["placement"] = placementName - h.logger.Info("auto-assigned placement", "placement", placementName) + // Validate placement availability before injecting service-set fields + placementName := managementClusters.Items[0].Name + if placementName == "" && (req.Spec["placement"] == nil || req.Spec["placement"] == "") { + h.logger.Error("management cluster has no name for placement", "cluster_id", managementClusters.Items[0].ID) + h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-CREATE-007", "Management cluster name not available for placement") + return } - if callerARN := middleware.GetCallerARN(ctx); callerARN != "" { - req.Spec["creatorARN"] = callerARN - } + conversion.InjectClusterServiceSet(req.Spec, conversion.ClusterServiceSetFields{ + CloudURL: cloudfrontURL, + Placement: placementName, + CreatorARN: middleware.GetCallerARN(ctx), + }) h.logger.Info("creating cluster", "account_id", accountID, "cluster_name", req.Name) @@ -175,7 +170,7 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { if cluster.Spec == nil { cluster.Spec = make(map[string]interface{}) } - cluster.Spec["cloudUrl"] = fmt.Sprintf("%s/%s", cloudfrontURL, cluster.ID) + conversion.RewriteCloudURLWithID(cluster.Spec, cloudfrontURL, cluster.ID) h.logger.Info("cluster created with cloudUrl", "cluster_id", cluster.ID, "cloudUrl", cluster.Spec["cloudUrl"]) From 2396173284d23e79e0eb84cc136d16c2812d1d8d Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Wed, 15 Jul 2026 12:10:36 -0500 Subject: [PATCH 4/6] ROSAENG-61805: Align OpenAPI spec with codegen-generated schemas (Phase 5) Add openapi-gen tool to codegen-install-tools and new codegen-openapi Makefile target that generates typed field definitions from api/v2alpha1/ Go types and merges them into openapi.yaml. Hidden fields (marked with +k8s:openapi-gen=false) are excluded. Cluster and nodepool spec sections now document visible fields with proper types instead of freeform objects. Co-Authored-By: Claude Opus 4.6 --- Makefile | 13 +- hack/merge-openapi.sh | 128 ++++++++++ openapi/generated-schemas.json | 445 +++++++++++++++++++++++++++++++++ openapi/openapi.yaml | 220 +++++++--------- 4 files changed, 682 insertions(+), 124 deletions(-) create mode 100755 hack/merge-openapi.sh create mode 100644 openapi/generated-schemas.json diff --git a/Makefile b/Makefile index 348bfabf..e4dc1eb6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet codegen-install-tools codegen-passthrough codegen-registry codegen-verify get-hypershift-version +.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet codegen-install-tools codegen-passthrough codegen-registry codegen-openapi codegen-verify get-hypershift-version BINARY_NAME := rosa-regional-platform-api IMAGE_REPO ?= quay.io/openshift-online/rosa-regional-platform-api @@ -359,6 +359,7 @@ HYPERSHIFT_TYPES ?= HostedClusterSpec,NodePoolSpec codegen-install-tools: GOBIN=$(PWD)/bin go install $(CODEGEN_TOOLS_MODULE)/cmd/passthrough-gen@$(CODEGEN_TOOLS_VERSION) GOBIN=$(PWD)/bin go install $(CODEGEN_TOOLS_MODULE)/cmd/marker-scanner@$(CODEGEN_TOOLS_VERSION) + GOBIN=$(PWD)/bin go install $(CODEGEN_TOOLS_MODULE)/cmd/openapi-gen@$(CODEGEN_TOOLS_VERSION) codegen-passthrough: codegen-install-tools @echo "Generating passthrough types from $(HYPERSHIFT_IMPORT_PATH)..." @@ -379,6 +380,16 @@ codegen-registry: codegen-install-tools --input-dirs=api/v2alpha1 \ --output-file=internal/codegen/registry/field_metadata.go +codegen-openapi: codegen-install-tools + @echo "Generating OpenAPI schemas from api/v2alpha1/..." + bin/openapi-gen \ + --input-dirs=api/v2alpha1 \ + --output-file=openapi/generated-schemas.json \ + --title="ROSA Regional Platform API" \ + --version=v2alpha1 + @echo "Merging generated schemas into openapi/openapi.yaml..." + hack/merge-openapi.sh openapi/generated-schemas.json openapi/openapi.yaml + codegen-verify: @echo "Verifying codegen packages compile..." go build ./api/v2alpha1/... diff --git a/hack/merge-openapi.sh b/hack/merge-openapi.sh new file mode 100755 index 00000000..094049f0 --- /dev/null +++ b/hack/merge-openapi.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Merges generated OpenAPI schemas from openapi-gen into the existing openapi.yaml. +# +# Usage: hack/merge-openapi.sh +# +# The generated JSON contains Swagger 2.0 definitions for the visible API types +# (hidden fields excluded by +k8s:openapi-gen=false markers). This script extracts +# the ClusterSpec and NodePoolSpec definitions and patches them into the +# corresponding spec: properties in the existing OpenAPI 3.0 YAML. + +set -euo pipefail + +GENERATED="${1:?Usage: $0 }" +OPENAPI="${2:?Usage: $0 }" + +if ! command -v yq &>/dev/null; then + echo "Error: yq is required. Install with: brew install yq" >&2 + exit 1 +fi + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Strip marker lines (+hyperfleet:..., +kubebuilder:..., +openshift:...) from JSON description strings +clean_markers() { + sed -E 's/\\n\+[^"]+//g' +} + +# Extract ClusterSpec visible properties (excluding hostedCluster which is all hidden) +yq eval -o=json ' + .definitions.ClusterSpec.properties + | to_entries + | map(select(.key != "hostedCluster")) + | from_entries +' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/cluster-spec.yaml" + +# Extract NodePoolSpec visible properties (excluding nodePool which is all hidden) +yq eval -o=json ' + .definitions.NodePoolSpec.properties + | to_entries + | map(select(.key != "nodePool")) + | from_entries +' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/nodepool-spec.yaml" + +# Extract ClusterReference for inline use +yq eval -o=json '.definitions.ClusterReference' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/cluster-ref.yaml" + +# --- Patch Cluster.spec --- +yq eval -i ' + .components.schemas.Cluster.properties.spec = { + "type": "object", + "description": "Cluster specification", + "additionalProperties": true + } +' "$OPENAPI" + +yq eval -i " + .components.schemas.Cluster.properties.spec.properties = load(\"$TMPDIR/cluster-spec.yaml\") +" "$OPENAPI" + +yq eval -i ' + .components.schemas.Cluster.properties.spec.properties.cloudUrl = { + "type": "string", + "readOnly": true, + "description": "CloudFront URL with cluster ID (auto-populated by server)", + "example": "https://doku78iof5s87.cloudfront.net/cluster-123" + } + | .components.schemas.Cluster.properties.spec.properties.placement = { + "type": "string", + "description": "Management cluster name (auto-populated if not provided)", + "example": "management-cluster-us-east-1" + } +' "$OPENAPI" + +# --- Patch ClusterCreateRequest.spec --- +yq eval -i ' + .components.schemas.ClusterCreateRequest.properties.spec = { + "type": "object", + "description": "Cluster specification", + "additionalProperties": true + } +' "$OPENAPI" + +yq eval -i " + .components.schemas.ClusterCreateRequest.properties.spec.properties = load(\"$TMPDIR/cluster-spec.yaml\") +" "$OPENAPI" + +yq eval -i ' + .components.schemas.ClusterCreateRequest.properties.spec.properties.placement = { + "type": "string", + "description": "Management cluster name (auto-populated if not provided)", + "example": "management-cluster-us-east-1" + } +' "$OPENAPI" + +# --- Patch ClusterUpdateRequest.spec --- +yq eval -i ' + .components.schemas.ClusterUpdateRequest.properties.spec = { + "type": "object", + "description": "Cluster specification (mutable fields only)", + "additionalProperties": true + } +' "$OPENAPI" + +yq eval -i " + .components.schemas.ClusterUpdateRequest.properties.spec.properties = load(\"$TMPDIR/cluster-spec.yaml\") +" "$OPENAPI" + +# --- Patch NodePoolSpec --- +yq eval -i ' + .components.schemas.NodePoolSpec = { + "type": "object", + "description": "NodePool specification defining desired state", + "additionalProperties": true + } +' "$OPENAPI" + +yq eval -i " + .components.schemas.NodePoolSpec.properties = load(\"$TMPDIR/nodepool-spec.yaml\") +" "$OPENAPI" + +# Inline ClusterReference into clusterRef (avoid $ref to external type) +yq eval -i " + .components.schemas.NodePoolSpec.properties.clusterRef = load(\"$TMPDIR/cluster-ref.yaml\") +" "$OPENAPI" + +echo "Merged generated schemas into $OPENAPI" diff --git a/openapi/generated-schemas.json b/openapi/generated-schemas.json new file mode 100644 index 00000000..a195e57e --- /dev/null +++ b/openapi/generated-schemas.json @@ -0,0 +1,445 @@ +{ + "swagger": "2.0", + "info": { + "description": "OpenAPI schema for ROSA Regional Platform API generated from Go types with markers\n\nFields marked with +k8s:openapi-gen=false are excluded from this schema.", + "title": "ROSA Regional Platform API", + "version": "v2alpha1" + }, + "paths": {}, + "definitions": { + "APIServerNetworkConfiguration": { + "type": "object" + }, + "Cluster": { + "description": "Cluster represents a HyperFleet managed OpenShift cluster\n+kubebuilder:object:root=true\n+kubebuilder:subresource:status\n+kubebuilder:resource:scope=Namespaced", + "type": "object", + "required": [ + "spec" + ], + "properties": { + "spec": { + "$ref": "#/definitions/ClusterSpec" + }, + "status": { + "$ref": "#/definitions/ClusterStatus" + } + } + }, + "ClusterAuthentication": { + "type": "object" + }, + "ClusterConfiguration": { + "description": "ClusterConfiguration specifies configuration for individual OCP components in the cluster.\nThis is a HyperFleet-owned mirror of hypershiftv1beta1.ClusterConfiguration that allows\nus to add granular markers to nested fields like kubelet config.", + "type": "object", + "properties": { + "kubelet": { + "description": "kubelet contains the configuration for kubelet on nodes.\nThis is where we can add granular control over kubelet fields.\n+hyperfleet:write-mode=service-set", + "$ref": "#/definitions/KubeletConfig" + }, + "machineConfig": { + "description": "machineConfig contains the configuration for machine-level settings (kernel params, systemd, files).\nGranular markers allow safe subset exposure while hiding dangerous operations.\n+hyperfleet:write-mode=service-set", + "$ref": "#/definitions/MachineConfigSpec" + } + } + }, + "ClusterList": { + "description": "ClusterList contains a list of Clusters\n+kubebuilder:object:root=true", + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "ClusterReference": { + "description": "ClusterReference identifies the parent cluster", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "description": "Name is the name of the Cluster resource\n+kubebuilder:validation:Required", + "type": "string" + }, + "namespace": { + "description": "Namespace is the namespace of the Cluster resource\nIf empty, defaults to the same namespace as this NodePool", + "type": "string" + } + } + }, + "ClusterSpec": { + "description": "ClusterSpec defines the desired state of a Cluster", + "type": "object", + "required": [ + "hostedCluster" + ], + "properties": { + "deleteProtection": { + "description": "DeleteProtection prevents accidental deletion when enabled\n+hyperfleet:write-mode=mutable", + "type": "boolean" + }, + "displayName": { + "description": "DisplayName is a human-readable name for the cluster\n+hyperfleet:write-mode=mutable\n+kubebuilder:validation:MaxLength=256", + "type": "string" + }, + "expirationTimestamp": { + "description": "ExpirationTimestamp marks when this cluster should be automatically deleted\n+hyperfleet:write-mode=mutable", + "type": "object" + }, + "hostedCluster": { + "description": "HostedCluster contains the full HyperShift HostedCluster configuration\nAll fields are generated from upstream and have safe defaults (hidden + service-set)\nuntil explicitly reviewed and exposed\n+kubebuilder:validation:Required", + "$ref": "#/definitions/HostedClusterSpecPassthrough" + }, + "properties": { + "description": "Properties are arbitrary key-value pairs for customer metadata\n+hyperfleet:write-mode=mutable", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "tags": { + "description": "Tags are customer-defined labels for organizational purposes\nThis is a TechPreview feature\n+hyperfleet:write-mode=mutable\n+openshift:enable:FeatureGate=HyperFleetAutoScaling", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ClusterStatus": { + "description": "ClusterStatus defines the observed state of a Cluster", + "type": "object", + "properties": { + "apiEndpoint": { + "description": "APIEndpoint is the cluster API server endpoint", + "type": "string" + }, + "conditions": { + "description": "Conditions represent detailed cluster status", + "type": "array", + "items": { + "type": "object" + } + }, + "consoleUrl": { + "description": "ConsoleURL is the web console URL", + "type": "string" + }, + "provisionStartTime": { + "description": "ProvisionStartTime is when provisioning began", + "type": "object" + }, + "readyTime": { + "description": "ReadyTime is when the cluster became ready", + "type": "object" + }, + "state": { + "description": "State represents the high-level cluster state\n+kubebuilder:validation:Enum=pending;provisioning;ready;degraded;deleting;failed", + "type": "string" + }, + "version": { + "description": "Version is the observed OpenShift version", + "type": "string" + } + } + }, + "FeatureGateConfiguration": { + "type": "object" + }, + "FileSpec": { + "description": "FileSpec represents a file to write to nodes.", + "type": "object", + "required": [ + "path" + ], + "properties": { + "contents": { + "description": "contents is the file contents", + "type": "string" + }, + "group": { + "description": "group is the file owner group", + "type": "string" + }, + "mode": { + "description": "mode is the file permissions (e.g., 0644)", + "type": "integer", + "format": "int32" + }, + "overwrite": { + "description": "overwrite specifies whether to overwrite existing files", + "type": "boolean" + }, + "path": { + "description": "path is the absolute path where the file should be written", + "type": "string" + }, + "user": { + "description": "user is the file owner user", + "type": "string" + } + } + }, + "HostedClusterSpecPassthrough": { + "description": "HostedClusterSpecPassthrough mirrors HostedClusterSpec from upstream HyperShift", + "type": "object" + }, + "ImageConfiguration": { + "type": "object" + }, + "IngressConfiguration": { + "type": "object" + }, + "KubeletConfig": { + "description": "KubeletConfig specifies kubelet configuration.\nThis is a HyperFleet-owned type that mirrors hypershiftv1beta1.KubeletConfig\nwith granular markers for customer control.", + "type": "object", + "properties": { + "containerLogMaxFiles": { + "description": "containerLogMaxFiles is the maximum number of container log files.\n+hyperfleet:write-mode=mutable", + "type": "integer", + "format": "int32" + }, + "containerLogMaxSize": { + "description": "containerLogMaxSize is the maximum size of container log file before it is rotated.\n+hyperfleet:write-mode=mutable", + "type": "string" + }, + "imageGCHighThresholdPercent": { + "description": "imageGCHighThresholdPercent is the disk usage percent triggering image GC.\n+hyperfleet:write-mode=mutable", + "type": "integer", + "format": "int32" + }, + "imageGCLowThresholdPercent": { + "description": "imageGCLowThresholdPercent is the disk usage percent to gc to.\n+hyperfleet:write-mode=mutable", + "type": "integer", + "format": "int32" + }, + "imageMinimumGCAge": { + "description": "imageMinimumGCAge is the minimum age for an unused image before it is garbage collected.\n+hyperfleet:write-mode=mutable", + "type": "object" + }, + "kubeReserved": { + "description": "kubeReserved specifies resources reserved for Kubernetes system components.\n+hyperfleet:write-mode=immutable", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "maxPods": { + "description": "maxPods is the maximum number of pods per node.\nCustomers can set this to optimize for high-density workloads.\n+hyperfleet:write-mode=mutable", + "type": "integer", + "format": "int32" + }, + "podPidsLimit": { + "description": "podPidsLimit is the maximum number of PIDs allowed per pod.\nCustomers can increase this for applications that spawn many processes.\n+hyperfleet:write-mode=mutable", + "type": "integer", + "format": "int64" + }, + "registryBurst": { + "description": "registryBurst is the maximum size of bursty pulls, temporarily allows pulls to burst.\n+openshift:enable:FeatureGate=HyperFleetKubeletAdvanced\n+hyperfleet:write-mode=mutable", + "type": "integer", + "format": "int32" + }, + "registryPullQPS": { + "description": "registryPullQPS is the limit of registry pulls per second.\n+openshift:enable:FeatureGate=HyperFleetKubeletAdvanced\n+hyperfleet:write-mode=mutable", + "type": "integer", + "format": "int32" + }, + "serializeImagePulls": { + "description": "serializeImagePulls when enabled, tells kubelet to pull images one at a time.\nTech preview feature for optimizing image pull performance.\n+openshift:enable:FeatureGate=HyperFleetKubeletAdvanced\n+hyperfleet:write-mode=mutable", + "type": "boolean" + }, + "streamingConnectionIdleTimeout": { + "description": "streamingConnectionIdleTimeout is the maximum time a streaming connection can be idle.\n+hyperfleet:write-mode=mutable", + "type": "object" + }, + "systemReserved": { + "description": "systemReserved specifies resources reserved for system daemons.\nCustomers can set this on cluster creation but cannot change it later.\n+hyperfleet:write-mode=immutable", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "MachineConfigSpec": { + "description": "MachineConfigSpec specifies machine-level configuration.\nThis controls kernel parameters, systemd units, and file writes.\nMost fields are platform-managed for security and stability.", + "type": "object", + "properties": { + "allowedKernelArguments": { + "description": "allowedKernelArguments specifies kernel parameters customers can request.\nThis is a WHITELIST approach - customers can only request known-safe parameters.\nPlatform validates against an allowlist and applies approved parameters.\nTech Preview feature requiring explicit enablement.\n+openshift:enable:FeatureGate=HyperFleetMachineConfig\n+hyperfleet:write-mode=immutable", + "type": "array", + "items": { + "type": "string" + } + }, + "fips": { + "description": "fips enables FIPS mode on nodes.\nImmutable - must be set at cluster creation, cannot be changed.\n+hyperfleet:write-mode=immutable", + "type": "boolean" + } + } + }, + "NetworkConfiguration": { + "type": "object" + }, + "NodePool": { + "description": "NodePool represents a HyperFleet managed NodePool for a cluster\n+kubebuilder:object:root=true\n+kubebuilder:subresource:status\n+kubebuilder:resource:scope=Namespaced", + "type": "object", + "required": [ + "spec" + ], + "properties": { + "spec": { + "$ref": "#/definitions/NodePoolSpec" + }, + "status": { + "$ref": "#/definitions/NodePoolStatus" + } + } + }, + "NodePoolList": { + "description": "NodePoolList contains a list of NodePools\n+kubebuilder:object:root=true", + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "NodePoolSpec": { + "description": "NodePoolSpec defines the desired state of a NodePool", + "type": "object", + "required": [ + "clusterRef", + "nodePool" + ], + "properties": { + "autoRepair": { + "description": "AutoRepair enables automatic repair of unhealthy nodes\n+hyperfleet:write-mode=mutable", + "type": "boolean" + }, + "clusterRef": { + "description": "ClusterRef references the parent Cluster\n+kubebuilder:validation:Required", + "$ref": "#/definitions/ClusterReference" + }, + "displayName": { + "description": "DisplayName is a human-readable name for the node pool\n+hyperfleet:write-mode=mutable", + "type": "string" + }, + "labels": { + "description": "Labels to apply to nodes in this pool\n+hyperfleet:write-mode=mutable", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "nodePool": { + "description": "NodePool contains the full HyperShift NodePool configuration\nAll fields are generated from upstream and have safe defaults (hidden + service-set)\n+kubebuilder:validation:Required", + "$ref": "#/definitions/NodePoolSpecPassthrough" + } + } + }, + "NodePoolSpecPassthrough": { + "description": "NodePoolSpecPassthrough mirrors NodePoolSpec from upstream HyperShift", + "type": "object" + }, + "NodePoolStatus": { + "description": "NodePoolStatus defines the observed state of a NodePool", + "type": "object", + "properties": { + "availableReplicas": { + "description": "AvailableReplicas is the number of available nodes", + "type": "integer", + "format": "int32" + }, + "conditions": { + "description": "Conditions represent detailed node pool status", + "type": "array", + "items": { + "type": "object" + } + }, + "readyReplicas": { + "description": "ReadyReplicas is the number of ready nodes", + "type": "integer", + "format": "int32" + }, + "replicas": { + "description": "Replicas is the current number of nodes", + "type": "integer", + "format": "int32" + }, + "state": { + "description": "State represents the high-level node pool state\n+kubebuilder:validation:Enum=pending;scaling;ready;degraded;deleting;failed", + "type": "string" + } + } + }, + "OAuthConfiguration": { + "type": "object" + }, + "ProxyConfiguration": { + "type": "object" + }, + "SchedulerConfiguration": { + "type": "object" + }, + "SystemdDropin": { + "description": "SystemdDropin represents a systemd drop-in configuration.", + "type": "object", + "required": [ + "name" + ], + "properties": { + "contents": { + "description": "contents is the drop-in file contents", + "type": "string" + }, + "name": { + "description": "name is the name of the drop-in file", + "type": "string" + } + } + }, + "SystemdUnit": { + "description": "SystemdUnit represents a systemd unit configuration.", + "type": "object", + "required": [ + "name" + ], + "properties": { + "contents": { + "description": "contents is the full systemd unit file contents", + "type": "string" + }, + "dropins": { + "description": "dropins are drop-in configurations for the unit", + "type": "array", + "items": { + "type": "object" + } + }, + "enabled": { + "description": "enabled specifies whether the unit is enabled", + "type": "boolean" + }, + "name": { + "description": "name is the name of the systemd unit (e.g., \"custom.service\")", + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index b5d2a803..d8df478b 100644 --- a/openapi/openapi.yaml +++ b/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: /management_clusters: post: @@ -52,7 +50,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - get: summary: List all management clusters description: | @@ -96,7 +93,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /management_clusters/{id}: get: summary: Get a management cluster by ID @@ -138,7 +134,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /resource_bundles: get: summary: List all resource bundles @@ -208,7 +203,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /work: post: summary: Create manifestwork for a cluster @@ -267,7 +261,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - # Cluster Management Endpoints /clusters: get: @@ -311,7 +304,6 @@ paths: $ref: '#/components/responses/Unauthorized' '500': $ref: '#/components/responses/InternalError' - post: summary: Create cluster description: | @@ -353,7 +345,6 @@ paths: $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalError' - /clusters/{id}: parameters: - name: id @@ -362,7 +353,6 @@ paths: schema: type: string description: Cluster ID - get: summary: Get cluster details description: Retrieve cluster details (user must own the cluster) @@ -386,7 +376,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - patch: summary: Update cluster description: Partially update cluster (user must own the cluster) @@ -416,7 +405,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - put: summary: Update cluster (deprecated) description: Update cluster (user must own the cluster). Deprecated - use PATCH instead for partial updates. @@ -447,7 +435,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - delete: summary: Delete cluster description: Delete cluster (user must own the cluster) @@ -483,7 +470,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - /clusters/{id}/statuses: parameters: - name: id @@ -492,7 +478,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) @@ -516,7 +501,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - # NodePool Management Endpoints /nodepools: get: @@ -559,7 +543,6 @@ paths: $ref: '#/components/responses/Unauthorized' '500': $ref: '#/components/responses/InternalError' - post: summary: Create nodepool description: Create a new nodepool for the authenticated user @@ -589,7 +572,6 @@ paths: $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalError' - /nodepools/{id}: parameters: - name: id @@ -598,7 +580,6 @@ paths: schema: type: string description: NodePool ID - get: summary: Get nodepool details description: Retrieve nodepool details (user must own the cluster) @@ -622,7 +603,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - put: summary: Update nodepool description: Update nodepool (user must own the cluster) @@ -652,7 +632,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - delete: summary: Delete nodepool description: Delete nodepool (user must own the cluster) @@ -681,7 +660,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - /nodepools/{id}/status: parameters: - name: id @@ -690,7 +668,6 @@ paths: schema: type: string description: NodePool ID - get: summary: Get nodepool status description: Retrieve nodepool status (user must own the cluster) @@ -714,7 +691,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' - /live: get: summary: Liveness probe @@ -729,7 +705,6 @@ paths: application/json: schema: $ref: '#/components/schemas/HealthStatus' - /ready: get: summary: Readiness probe @@ -750,7 +725,6 @@ paths: application/json: schema: $ref: '#/components/schemas/HealthStatus' - # Authorization - Account Management /accounts: post: @@ -826,7 +800,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /accounts/{id}: get: summary: Get an account @@ -899,7 +872,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - # Authorization - Check /authz/check: post: @@ -941,7 +913,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - # Authorization - Policy Management /authz/policies: post: @@ -1023,7 +994,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /authz/policies/{id}: get: summary: Get a policy @@ -1153,7 +1123,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - # Authorization - Group Management /authz/groups: post: @@ -1238,7 +1207,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /authz/groups/{id}: get: summary: Get a group @@ -1316,7 +1284,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /authz/groups/{id}/members: get: summary: List group members @@ -1421,7 +1388,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - # Authorization - Attachment Management /authz/attachments: post: @@ -1533,7 +1499,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /authz/attachments/{id}: delete: summary: Delete an attachment @@ -1572,7 +1537,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - # Authorization - Admin Management /authz/admins: post: @@ -1659,7 +1623,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /authz/admins/{arn}: delete: summary: Remove an admin @@ -1695,7 +1658,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - components: schemas: ManagementClusterRequest: @@ -1715,7 +1677,6 @@ components: description: Key-value labels for the management cluster additionalProperties: type: string - ManagementCluster: type: object description: A management cluster (passthrough from Maestro consumer) @@ -1751,7 +1712,6 @@ components: type: string format: date-time description: Last update timestamp - ManagementClusterList: type: object description: Paginated list of management clusters @@ -1779,7 +1739,6 @@ components: type: array items: $ref: '#/components/schemas/ManagementCluster' - ResourceBundle: type: object description: A resource bundle containing manifests for deployment @@ -1841,7 +1800,6 @@ components: status: type: object description: Status of the resource bundle - ResourceBundleList: type: object description: Paginated list of resource bundles @@ -1869,7 +1827,6 @@ components: type: array items: $ref: '#/components/schemas/ResourceBundle' - WorkRequest: type: object description: Request body for creating manifestwork @@ -1886,7 +1843,6 @@ components: Payload data to be passed to Maestro gRPC for creating the manifestwork. This should contain the ManifestWork specification including manifests. additionalProperties: true - Work: type: object description: A manifestwork resource created for a cluster @@ -1925,7 +1881,6 @@ components: status: type: object description: Status of the manifestwork - Error: type: object description: Error response @@ -1952,7 +1907,6 @@ components: operation_id: type: string description: Request operation ID for tracing - HealthStatus: type: object description: Health check response @@ -1961,7 +1915,6 @@ components: type: string enum: [ok, degraded, unavailable] description: Health status - # Authorization Schemas EnableAccountRequest: type: object @@ -1976,7 +1929,6 @@ components: type: boolean description: If true, account bypasses all authorization checks default: false - Account: type: object description: An enabled account @@ -2002,7 +1954,6 @@ components: createdBy: type: string description: ARN of who enabled the account - AccountList: type: object description: List of accounts @@ -2021,7 +1972,6 @@ components: total: type: integer description: Total number of accounts - CheckAuthorizationRequest: type: object description: Request body for checking authorization @@ -2048,7 +1998,6 @@ components: description: Tags on the resource additionalProperties: type: string - CheckAuthorizationResponse: type: object description: Authorization decision response @@ -2066,7 +2015,6 @@ components: reason: type: string description: Reason for the decision - CreatePolicyRequest: type: object description: Request body for creating a policy @@ -2085,7 +2033,6 @@ components: maxLength: 1024 policy: $ref: '#/components/schemas/V0Policy' - UpdatePolicyRequest: type: object description: Request body for updating a policy @@ -2101,7 +2048,6 @@ components: maxLength: 1024 policy: $ref: '#/components/schemas/V0Policy' - V0Policy: type: object description: IAM-like policy in v0 format @@ -2119,7 +2065,6 @@ components: minItems: 1 items: $ref: '#/components/schemas/PolicyStatement' - PolicyStatement: type: object description: A single statement in a policy @@ -2150,7 +2095,6 @@ components: description: Optional conditions (operator -> key -> value) additionalProperties: $ref: '#/components/schemas/PolicyCondition' - PolicyCondition: type: object description: Condition key-value pairs @@ -2162,7 +2106,6 @@ components: - type: array items: type: string - Policy: type: object description: A policy template @@ -2201,7 +2144,6 @@ components: type: string format: date-time description: Last update timestamp - PolicyList: type: object description: Paginated list of policies @@ -2225,7 +2167,6 @@ components: type: array items: $ref: '#/components/schemas/Policy' - CreateGroupRequest: type: object description: Request body for creating a group @@ -2241,7 +2182,6 @@ components: type: string description: Optional group description maxLength: 1024 - Group: type: object description: A group of users @@ -2275,7 +2215,6 @@ components: type: string format: date-time description: Creation timestamp - GroupList: type: object description: Paginated list of groups @@ -2299,7 +2238,6 @@ components: type: array items: $ref: '#/components/schemas/Group' - GroupMember: type: object description: A member of a group @@ -2314,7 +2252,6 @@ components: type: string format: date-time description: When the member was added - GroupMemberList: type: object description: Paginated list of group members @@ -2338,7 +2275,6 @@ components: type: array items: $ref: '#/components/schemas/GroupMember' - UpdateGroupMembersRequest: type: object description: Request body for updating group members @@ -2353,7 +2289,6 @@ components: description: ARNs to remove from the group items: type: string - CreateAttachmentRequest: type: object description: Request body for creating an attachment @@ -2373,7 +2308,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 @@ -2417,7 +2351,6 @@ components: type: string format: date-time description: Creation timestamp - AttachmentList: type: object description: Paginated list of attachments @@ -2441,7 +2374,6 @@ components: type: array items: $ref: '#/components/schemas/Attachment' - AddAdminRequest: type: object description: Request body for adding an admin @@ -2451,7 +2383,6 @@ components: principalArn: type: string description: ARN of the principal to make admin - Admin: type: object description: An admin for the account @@ -2469,7 +2400,6 @@ components: createdBy: type: string description: ARN of who added this admin - AdminList: type: object description: Paginated list of admins @@ -2493,7 +2423,6 @@ components: type: array items: $ref: '#/components/schemas/Admin' - # Cluster Schemas Cluster: type: object @@ -2530,23 +2459,39 @@ components: description: Resource version for optimistic concurrency control spec: type: object - description: | - Cluster specification. When a cluster is created, the response includes - auto-populated fields like cloudUrl and placement. + description: Cluster specification + additionalProperties: true properties: + deleteProtection: + description: DeleteProtection prevents accidental deletion when enabled + type: boolean + displayName: + description: DisplayName is a human-readable name for the cluster + type: string + expirationTimestamp: + description: ExpirationTimestamp marks when this cluster should be automatically deleted + type: object + properties: + description: Properties are arbitrary key-value pairs for customer metadata + type: object + additionalProperties: + type: string + tags: + description: |- + Tags are customer-defined labels for organizational purposes + This is a TechPreview feature + type: object + additionalProperties: + type: string cloudUrl: type: string - description: | - CloudFront URL with cluster ID appended (format: {cloudfront_url}/{cluster_id}). - This field is automatically added to the response when creating a cluster. + readOnly: true + description: CloudFront URL with cluster ID (auto-populated by server) example: https://doku78iof5s87.cloudfront.net/cluster-123 placement: type: string - description: | - Management cluster name where the cluster is deployed. - Auto-populated from the first management cluster if not provided during creation. + description: Management cluster name (auto-populated if not provided) example: management-cluster-us-east-1 - additionalProperties: true status: $ref: '#/components/schemas/ClusterStatusInfo' created_at: @@ -2557,7 +2502,6 @@ components: type: string format: date-time description: Last update timestamp - ClusterCreateRequest: type: object description: | @@ -2581,19 +2525,34 @@ components: description: Target project ID spec: type: object - description: | - Cluster specification. The following fields are auto-populated if not provided: - - placement: Management cluster name where the cluster will be deployed (optional, auto-assigned if omitted) - - cloudUrl: CloudFront URL (always auto-populated from management cluster) + description: Cluster specification + additionalProperties: true properties: + deleteProtection: + description: DeleteProtection prevents accidental deletion when enabled + type: boolean + displayName: + description: DisplayName is a human-readable name for the cluster + type: string + expirationTimestamp: + description: ExpirationTimestamp marks when this cluster should be automatically deleted + type: object + properties: + description: Properties are arbitrary key-value pairs for customer metadata + type: object + additionalProperties: + type: string + tags: + description: |- + Tags are customer-defined labels for organizational purposes + This is a TechPreview feature + type: object + additionalProperties: + type: string placement: type: string - description: | - Management cluster name where the cluster will be deployed. - If not provided, will be auto-populated from the first available management cluster. + description: Management cluster name (auto-populated if not provided) example: management-cluster-us-east-1 - additionalProperties: true - ClusterUpdateRequest: type: object description: Request body for updating a cluster @@ -2602,9 +2561,30 @@ components: properties: spec: type: object - description: Cluster specification + description: Cluster specification (mutable fields only) additionalProperties: true - + properties: + deleteProtection: + description: DeleteProtection prevents accidental deletion when enabled + type: boolean + displayName: + description: DisplayName is a human-readable name for the cluster + type: string + expirationTimestamp: + description: ExpirationTimestamp marks when this cluster should be automatically deleted + type: object + properties: + description: Properties are arbitrary key-value pairs for customer metadata + type: object + additionalProperties: + type: string + tags: + description: |- + Tags are customer-defined labels for organizational purposes + This is a TechPreview feature + type: object + additionalProperties: + type: string ClusterStatusInfo: type: object description: Kubernetes-like aggregated status for clusters @@ -2632,7 +2612,6 @@ components: type: string format: date-time description: When status was last calculated - Condition: type: object description: Status condition @@ -2654,7 +2633,6 @@ components: message: type: string description: Human-readable message - ClusterControllerStatus: type: object description: Controller-specific status for a cluster @@ -2686,7 +2664,6 @@ components: type: string format: date-time description: When this controller last updated - ClusterStatusResponse: type: object description: Response for cluster status endpoint @@ -2701,7 +2678,6 @@ components: description: Individual controller status reports items: $ref: '#/components/schemas/ClusterControllerStatus' - ClusterList: type: object description: Paginated list of clusters @@ -2724,7 +2700,6 @@ components: offset: type: integer description: Number of items skipped - # NodePool Schemas NodePool: type: object @@ -2771,7 +2746,6 @@ components: type: string format: date-time description: Last update timestamp - NodePoolCreateRequest: type: object description: Request body for creating a nodepool @@ -2791,7 +2765,6 @@ components: description: DNS-compatible nodepool name spec: $ref: '#/components/schemas/NodePoolSpec' - NodePoolUpdateRequest: type: object description: Request body for updating a nodepool @@ -2800,31 +2773,36 @@ components: properties: spec: $ref: '#/components/schemas/NodePoolSpec' - NodePoolSpec: type: object description: NodePool specification defining desired state + additionalProperties: true properties: - replicas: - type: integer - format: int32 - description: Number of worker nodes in the pool - management: - type: object - description: Node pool management configuration - additionalProperties: true - platform: - type: object - description: Platform-specific configuration - additionalProperties: true - release: + autoRepair: + description: AutoRepair enables automatic repair of unhealthy nodes + type: boolean + clusterRef: + description: ClusterReference identifies the parent cluster type: object - description: Release/version configuration - additionalProperties: true - nodeDrainTimeout: + required: + - name + properties: + name: + description: Name is the name of the Cluster resource + type: string + namespace: + description: |- + Namespace is the namespace of the Cluster resource + If empty, defaults to the same namespace as this NodePool + type: string + displayName: + description: DisplayName is a human-readable name for the node pool type: string - description: Timeout for draining nodes - + labels: + description: Labels to apply to nodes in this pool + type: object + additionalProperties: + type: string NodePoolStatusInfo: type: object description: Kubernetes-like aggregated status for nodepools @@ -2852,7 +2830,6 @@ components: type: string format: date-time description: When status was last calculated - NodePoolControllerStatus: type: object description: Controller-specific status for a nodepool @@ -2884,7 +2861,6 @@ components: type: string format: date-time description: When this controller last updated - NodePoolStatusResponse: type: object description: Response for nodepool status endpoint @@ -2899,7 +2875,6 @@ components: description: Individual controller status reports items: $ref: '#/components/schemas/NodePoolControllerStatus' - NodePoolList: type: object description: Paginated list of nodepools @@ -2922,7 +2897,6 @@ components: offset: type: integer description: Number of items skipped - responses: BadRequest: description: Bad request From 98d360c2c175c9fd66cffab56f7b0aa54afb6bca Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Wed, 15 Jul 2026 12:19:06 -0500 Subject: [PATCH 5/6] ROSAENG-61805: Add swagger-ui-serve and swagger-ui-open Makefile targets Add openapi/swagger-ui/index.html that loads openapi.yaml dynamically via CDN-hosted Swagger UI. The swagger-ui-serve target starts a local Python HTTP server for browsing the API docs. Co-Authored-By: Claude Opus 4.6 --- Makefile | 14 +++++++++++++- openapi/swagger-ui/index.html | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 openapi/swagger-ui/index.html diff --git a/Makefile b/Makefile index e4dc1eb6..3aada7e8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet codegen-install-tools codegen-passthrough codegen-registry codegen-openapi codegen-verify get-hypershift-version +.PHONY: build test test-unit test-authz test-coverage test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa lint clean image image-push run generate generate-swagger help fmt vet codegen-install-tools codegen-passthrough codegen-registry codegen-openapi codegen-verify get-hypershift-version swagger-ui-serve swagger-ui-open BINARY_NAME := rosa-regional-platform-api IMAGE_REPO ?= quay.io/openshift-online/rosa-regional-platform-api @@ -410,5 +410,17 @@ get-hypershift-version: ## Show current HyperShift version in go.mod echo " Tag: $$TAG"; \ fi +swagger-ui-serve: ## Serve Swagger UI locally (requires Python 3) + @command -v python3 >/dev/null 2>&1 || { echo "Error: python3 is required"; exit 1; } + @echo "Swagger UI: http://localhost:8080/openapi/swagger-ui/" + @echo "OpenAPI spec: http://localhost:8080/openapi/openapi.yaml" + @echo "Press Ctrl+C to stop" + @python3 -m http.server 8080 --directory . + +swagger-ui-open: ## Open Swagger UI in browser (requires swagger-ui-serve running) + @command -v open >/dev/null 2>&1 && open http://localhost:8080/openapi/swagger-ui/ || \ + command -v xdg-open >/dev/null 2>&1 && xdg-open http://localhost:8080/openapi/swagger-ui/ || \ + echo "Open http://localhost:8080/openapi/swagger-ui/ in your browser" + # All checks all: deps fmt vet lint test build diff --git a/openapi/swagger-ui/index.html b/openapi/swagger-ui/index.html new file mode 100644 index 00000000..b6724d27 --- /dev/null +++ b/openapi/swagger-ui/index.html @@ -0,0 +1,30 @@ + + + + + ROSA Regional Platform API - Swagger UI + + + + +
+ + + + + From 3515edc0a1b560e6b068de7126eabd80d4c9c59a Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Wed, 15 Jul 2026 13:01:13 -0500 Subject: [PATCH 6/6] ROSAENG-61805: Add --keep-markers flag, standalone ClusterSpec schema, and make help updates - Extract ClusterSpec as standalone schema with $ref from Cluster/ClusterCreateRequest/ClusterUpdateRequest - Add --keep-markers flag to merge-openapi.sh to preserve Go marker annotations and include hidden passthrough objects (hostedCluster, nodePool) with all referenced definitions - Import all generated definitions as OpenAPI 3.0 schemas when --keep-markers is set - Add KEEP_MARKERS and VERBOSE variables to Makefile for codegen-openapi and codegen-registry - Update make help with codegen-openapi, get-hypershift-version, and API Documentation section Co-Authored-By: Claude Opus 4.6 --- Makefile | 17 +- hack/merge-openapi.sh | 141 +++++++---- openapi/openapi.yaml | 553 +++++++++++++++++++++++++++--------------- 3 files changed, 471 insertions(+), 240 deletions(-) diff --git a/Makefile b/Makefile index 3aada7e8..46f69eba 100644 --- a/Makefile +++ b/Makefile @@ -81,10 +81,16 @@ help: @echo " generate-swagger - Regenerate swagger-ui.html" @echo "" @echo "Codegen Integration:" - @echo " codegen-install-tools - Install passthrough-gen and marker-scanner binaries" + @echo " codegen-install-tools - Install passthrough-gen, marker-scanner, and openapi-gen binaries" @echo " codegen-passthrough - Regenerate passthrough types from HyperShift CRDs" @echo " codegen-registry - Regenerate field metadata registry from annotated types" + @echo " codegen-openapi - Generate OpenAPI schemas from Go types and merge into openapi.yaml" @echo " codegen-verify - Verify codegen and dependent packages compile" + @echo " get-hypershift-version - Show current HyperShift version in go.mod" + @echo "" + @echo "API Documentation:" + @echo " swagger-ui-serve - Serve Swagger UI locally (requires Python 3)" + @echo " swagger-ui-open - Open Swagger UI in browser (requires swagger-ui-serve running)" @echo "" @echo " all - Run all checks (deps, fmt, vet, lint, test, build)" @@ -374,11 +380,16 @@ codegen-passthrough: codegen-install-tools fi @echo "Done. Edit api/v2alpha1/hostedclusterspec.passthrough.go to curate field markers." +VERBOSE ?= + codegen-registry: codegen-install-tools @echo "Generating field metadata registry from api/v2alpha1/..." bin/marker-scanner \ --input-dirs=api/v2alpha1 \ - --output-file=internal/codegen/registry/field_metadata.go + --output-file=internal/codegen/registry/field_metadata.go \ + $(if $(VERBOSE),--verbose) + +KEEP_MARKERS ?= codegen-openapi: codegen-install-tools @echo "Generating OpenAPI schemas from api/v2alpha1/..." @@ -388,7 +399,7 @@ codegen-openapi: codegen-install-tools --title="ROSA Regional Platform API" \ --version=v2alpha1 @echo "Merging generated schemas into openapi/openapi.yaml..." - hack/merge-openapi.sh openapi/generated-schemas.json openapi/openapi.yaml + hack/merge-openapi.sh $(if $(KEEP_MARKERS),--keep-markers) openapi/generated-schemas.json openapi/openapi.yaml codegen-verify: @echo "Verifying codegen packages compile..." diff --git a/hack/merge-openapi.sh b/hack/merge-openapi.sh index 094049f0..133b620c 100755 --- a/hack/merge-openapi.sh +++ b/hack/merge-openapi.sh @@ -2,17 +2,31 @@ # # Merges generated OpenAPI schemas from openapi-gen into the existing openapi.yaml. # -# Usage: hack/merge-openapi.sh +# Usage: hack/merge-openapi.sh [--keep-markers] +# +# Flags: +# --keep-markers Preserve Go marker annotations (+k8s:, +hyperfleet:, etc.) in descriptions +# and include hidden passthrough objects (hostedCluster, nodePool) # # The generated JSON contains Swagger 2.0 definitions for the visible API types # (hidden fields excluded by +k8s:openapi-gen=false markers). This script extracts # the ClusterSpec and NodePoolSpec definitions and patches them into the # corresponding spec: properties in the existing OpenAPI 3.0 YAML. +# +# ClusterSpec and NodePoolSpec are created as standalone schemas under +# components.schemas so they appear in Swagger UI. The Cluster, +# ClusterCreateRequest, and ClusterUpdateRequest schemas reference them via $ref. set -euo pipefail -GENERATED="${1:?Usage: $0 }" -OPENAPI="${2:?Usage: $0 }" +KEEP_MARKERS=false +if [[ "${1:-}" == "--keep-markers" ]]; then + KEEP_MARKERS=true + shift +fi + +GENERATED="${1:?Usage: $0 [--keep-markers] }" +OPENAPI="${2:?Usage: $0 [--keep-markers] }" if ! command -v yq &>/dev/null; then echo "Error: yq is required. Install with: brew install yq" >&2 @@ -24,90 +38,118 @@ trap 'rm -rf "$TMPDIR"' EXIT # Strip marker lines (+hyperfleet:..., +kubebuilder:..., +openshift:...) from JSON description strings clean_markers() { - sed -E 's/\\n\+[^"]+//g' + if [[ "$KEEP_MARKERS" == "true" ]]; then + cat + else + sed -E 's/\\n\+[^"]+//g' + fi } -# Extract ClusterSpec visible properties (excluding hostedCluster which is all hidden) -yq eval -o=json ' - .definitions.ClusterSpec.properties - | to_entries - | map(select(.key != "hostedCluster")) - | from_entries -' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/cluster-spec.yaml" - -# Extract NodePoolSpec visible properties (excluding nodePool which is all hidden) -yq eval -o=json ' - .definitions.NodePoolSpec.properties - | to_entries - | map(select(.key != "nodePool")) - | from_entries -' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/nodepool-spec.yaml" +# Extract ClusterSpec properties +if [[ "$KEEP_MARKERS" == "true" ]]; then + yq eval -o=json '.definitions.ClusterSpec.properties' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/cluster-spec.yaml" +else + yq eval -o=json ' + .definitions.ClusterSpec.properties + | to_entries + | map(select(.key != "hostedCluster")) + | from_entries + ' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/cluster-spec.yaml" +fi + +# Extract NodePoolSpec properties +if [[ "$KEEP_MARKERS" == "true" ]]; then + yq eval -o=json '.definitions.NodePoolSpec.properties' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/nodepool-spec.yaml" +else + yq eval -o=json ' + .definitions.NodePoolSpec.properties + | to_entries + | map(select(.key != "nodePool")) + | from_entries + ' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/nodepool-spec.yaml" +fi # Extract ClusterReference for inline use yq eval -o=json '.definitions.ClusterReference' "$GENERATED" | clean_markers | yq eval -P '.' > "$TMPDIR/cluster-ref.yaml" -# --- Patch Cluster.spec --- +# --- Import all generated definitions as standalone schemas (dev mode) --- +if [[ "$KEEP_MARKERS" == "true" ]]; then + # Extract every definition from the generated Swagger 2.0 JSON, excluding + # top-level types already handled (ClusterSpec, NodePoolSpec, ClusterReference) + SKIP_DEFS="ClusterSpec NodePoolSpec ClusterReference" + for def in $(yq eval -r '.definitions | keys | .[]' "$GENERATED"); do + skip=false + for s in $SKIP_DEFS; do + if [[ "$def" == "$s" ]]; then skip=true; break; fi + done + if [[ "$skip" == "true" ]]; then continue; fi + + yq eval -o=json ".definitions.\"${def}\"" "$GENERATED" \ + | clean_markers \ + | yq eval -P '.' > "$TMPDIR/def-${def}.yaml" + + yq eval -i " + .components.schemas.\"${def}\" = load(\"$TMPDIR/def-${def}.yaml\") + " "$OPENAPI" + done + +fi + +# --- Create standalone ClusterSpec schema --- yq eval -i ' - .components.schemas.Cluster.properties.spec = { + .components.schemas.ClusterSpec = { "type": "object", - "description": "Cluster specification", + "description": "Cluster specification defining desired state", "additionalProperties": true } ' "$OPENAPI" yq eval -i " - .components.schemas.Cluster.properties.spec.properties = load(\"$TMPDIR/cluster-spec.yaml\") + .components.schemas.ClusterSpec.properties = load(\"$TMPDIR/cluster-spec.yaml\") " "$OPENAPI" yq eval -i ' - .components.schemas.Cluster.properties.spec.properties.cloudUrl = { + .components.schemas.ClusterSpec.properties.cloudUrl = { "type": "string", "readOnly": true, "description": "CloudFront URL with cluster ID (auto-populated by server)", "example": "https://doku78iof5s87.cloudfront.net/cluster-123" } - | .components.schemas.Cluster.properties.spec.properties.placement = { + | .components.schemas.ClusterSpec.properties.placement = { "type": "string", "description": "Management cluster name (auto-populated if not provided)", "example": "management-cluster-us-east-1" } ' "$OPENAPI" -# --- Patch ClusterCreateRequest.spec --- +# --- Patch Cluster.spec to $ref ClusterSpec --- yq eval -i ' - .components.schemas.ClusterCreateRequest.properties.spec = { - "type": "object", - "description": "Cluster specification", - "additionalProperties": true + .components.schemas.Cluster.properties.spec = { + "$ref": "#/components/schemas/ClusterSpec" } ' "$OPENAPI" -yq eval -i " - .components.schemas.ClusterCreateRequest.properties.spec.properties = load(\"$TMPDIR/cluster-spec.yaml\") -" "$OPENAPI" - +# --- Patch ClusterCreateRequest.spec to allOf ClusterSpec (without cloudUrl) --- yq eval -i ' - .components.schemas.ClusterCreateRequest.properties.spec.properties.placement = { - "type": "string", - "description": "Management cluster name (auto-populated if not provided)", - "example": "management-cluster-us-east-1" + .components.schemas.ClusterCreateRequest.properties.spec = { + "allOf": [ + {"$ref": "#/components/schemas/ClusterSpec"} + ], + "description": "Cluster specification (cloudUrl is server-populated and ignored on create)" } ' "$OPENAPI" -# --- Patch ClusterUpdateRequest.spec --- +# --- Patch ClusterUpdateRequest.spec to allOf ClusterSpec --- yq eval -i ' .components.schemas.ClusterUpdateRequest.properties.spec = { - "type": "object", - "description": "Cluster specification (mutable fields only)", - "additionalProperties": true + "allOf": [ + {"$ref": "#/components/schemas/ClusterSpec"} + ], + "description": "Cluster specification (mutable fields only)" } ' "$OPENAPI" -yq eval -i " - .components.schemas.ClusterUpdateRequest.properties.spec.properties = load(\"$TMPDIR/cluster-spec.yaml\") -" "$OPENAPI" - -# --- Patch NodePoolSpec --- +# --- Patch NodePoolSpec as standalone schema --- yq eval -i ' .components.schemas.NodePoolSpec = { "type": "object", @@ -125,4 +167,9 @@ yq eval -i " .components.schemas.NodePoolSpec.properties.clusterRef = load(\"$TMPDIR/cluster-ref.yaml\") " "$OPENAPI" +# Rewrite $ref paths from Swagger 2.0 (#/definitions/X) to OpenAPI 3.0 (#/components/schemas/X) +if [[ "$KEEP_MARKERS" == "true" ]]; then + sed -i '' "s|#/definitions/|#/components/schemas/|g" "$OPENAPI" +fi + echo "Merged generated schemas into $OPENAPI" diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index d8df478b..570a9f3e 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2425,83 +2425,19 @@ components: $ref: '#/components/schemas/Admin' # Cluster Schemas Cluster: + description: |- + Cluster represents a HyperFleet managed OpenShift cluster + +kubebuilder:object:root=true + +kubebuilder:subresource:status + +kubebuilder:resource:scope=Namespaced type: object - description: A user cluster resource required: - - id - - name - - target_project_id - - created_by - - generation - - resource_version - spec - - created_at - - updated_at properties: - id: - type: string - description: Unique identifier for the cluster - name: - type: string - description: Cluster name - target_project_id: - type: string - description: Target project ID - created_by: - type: string - description: Email of the user who created this cluster - generation: - type: integer - format: int64 - description: Generation number for optimistic concurrency control - resource_version: - type: string - description: Resource version for optimistic concurrency control spec: - type: object - description: Cluster specification - additionalProperties: true - properties: - deleteProtection: - description: DeleteProtection prevents accidental deletion when enabled - type: boolean - displayName: - description: DisplayName is a human-readable name for the cluster - type: string - expirationTimestamp: - description: ExpirationTimestamp marks when this cluster should be automatically deleted - type: object - properties: - description: Properties are arbitrary key-value pairs for customer metadata - type: object - additionalProperties: - type: string - tags: - description: |- - Tags are customer-defined labels for organizational purposes - This is a TechPreview feature - type: object - additionalProperties: - type: string - cloudUrl: - type: string - readOnly: true - description: CloudFront URL with cluster ID (auto-populated by server) - example: https://doku78iof5s87.cloudfront.net/cluster-123 - placement: - type: string - description: Management cluster name (auto-populated if not provided) - example: management-cluster-us-east-1 + $ref: '#/components/schemas/ClusterSpec' status: - $ref: '#/components/schemas/ClusterStatusInfo' - created_at: - type: string - format: date-time - description: Creation timestamp - updated_at: - type: string - format: date-time - description: Last update timestamp + $ref: '#/components/schemas/ClusterStatus' ClusterCreateRequest: type: object description: | @@ -2524,35 +2460,9 @@ components: type: string description: Target project ID spec: - type: object - description: Cluster specification - additionalProperties: true - properties: - deleteProtection: - description: DeleteProtection prevents accidental deletion when enabled - type: boolean - displayName: - description: DisplayName is a human-readable name for the cluster - type: string - expirationTimestamp: - description: ExpirationTimestamp marks when this cluster should be automatically deleted - type: object - properties: - description: Properties are arbitrary key-value pairs for customer metadata - type: object - additionalProperties: - type: string - tags: - description: |- - Tags are customer-defined labels for organizational purposes - This is a TechPreview feature - type: object - additionalProperties: - type: string - placement: - type: string - description: Management cluster name (auto-populated if not provided) - example: management-cluster-us-east-1 + allOf: + - $ref: '#/components/schemas/ClusterSpec' + description: Cluster specification (cloudUrl is server-populated and ignored on create) ClusterUpdateRequest: type: object description: Request body for updating a cluster @@ -2560,31 +2470,9 @@ components: - spec properties: spec: - type: object + allOf: + - $ref: '#/components/schemas/ClusterSpec' description: Cluster specification (mutable fields only) - additionalProperties: true - properties: - deleteProtection: - description: DeleteProtection prevents accidental deletion when enabled - type: boolean - displayName: - description: DisplayName is a human-readable name for the cluster - type: string - expirationTimestamp: - description: ExpirationTimestamp marks when this cluster should be automatically deleted - type: object - properties: - description: Properties are arbitrary key-value pairs for customer metadata - type: object - additionalProperties: - type: string - tags: - description: |- - Tags are customer-defined labels for organizational purposes - This is a TechPreview feature - type: object - additionalProperties: - type: string ClusterStatusInfo: type: object description: Kubernetes-like aggregated status for clusters @@ -2679,73 +2567,32 @@ components: items: $ref: '#/components/schemas/ClusterControllerStatus' ClusterList: + description: |- + ClusterList contains a list of Clusters + +kubebuilder:object:root=true type: object - description: Paginated list of clusters required: - items - - total - - limit - - offset properties: items: type: array items: - $ref: '#/components/schemas/Cluster' - total: - type: integer - description: Total number of clusters - limit: - type: integer - description: Number of items per page - offset: - type: integer - description: Number of items skipped + type: object # NodePool Schemas NodePool: + description: |- + NodePool represents a HyperFleet managed NodePool for a cluster + +kubebuilder:object:root=true + +kubebuilder:subresource:status + +kubebuilder:resource:scope=Namespaced type: object - description: A nodepool resource required: - - id - - cluster_id - - name - - created_by - - generation - - resource_version - spec - - created_at - - updated_at properties: - id: - type: string - description: Unique identifier for the nodepool - cluster_id: - type: string - description: Parent cluster ID - name: - type: string - description: NodePool name - created_by: - type: string - description: Email of the user who created this nodepool - generation: - type: integer - format: int64 - description: Generation number for optimistic concurrency control - resource_version: - type: string - description: Resource version for optimistic concurrency control spec: $ref: '#/components/schemas/NodePoolSpec' status: - $ref: '#/components/schemas/NodePoolStatusInfo' - created_at: - type: string - format: date-time - description: Creation timestamp - updated_at: - type: string - format: date-time - description: Last update timestamp + $ref: '#/components/schemas/NodePoolStatus' NodePoolCreateRequest: type: object description: Request body for creating a nodepool @@ -2779,7 +2626,9 @@ components: additionalProperties: true properties: autoRepair: - description: AutoRepair enables automatic repair of unhealthy nodes + description: |- + AutoRepair enables automatic repair of unhealthy nodes + +hyperfleet:write-mode=mutable type: boolean clusterRef: description: ClusterReference identifies the parent cluster @@ -2788,7 +2637,9 @@ components: - name properties: name: - description: Name is the name of the Cluster resource + description: |- + Name is the name of the Cluster resource + +kubebuilder:validation:Required type: string namespace: description: |- @@ -2796,13 +2647,23 @@ components: If empty, defaults to the same namespace as this NodePool type: string displayName: - description: DisplayName is a human-readable name for the node pool + description: |- + DisplayName is a human-readable name for the node pool + +hyperfleet:write-mode=mutable type: string labels: - description: Labels to apply to nodes in this pool + description: |- + Labels to apply to nodes in this pool + +hyperfleet:write-mode=mutable type: object additionalProperties: type: string + nodePool: + description: |- + NodePool contains the full HyperShift NodePool configuration + All fields are generated from upstream and have safe defaults (hidden + service-set) + +kubebuilder:validation:Required + $ref: '#/components/schemas/NodePoolSpecPassthrough' NodePoolStatusInfo: type: object description: Kubernetes-like aggregated status for nodepools @@ -2876,27 +2737,339 @@ components: items: $ref: '#/components/schemas/NodePoolControllerStatus' NodePoolList: + description: |- + NodePoolList contains a list of NodePools + +kubebuilder:object:root=true type: object - description: Paginated list of nodepools required: - items - - total - - limit - - offset properties: items: type: array items: - $ref: '#/components/schemas/NodePool' - total: + type: object + APIServerNetworkConfiguration: + type: object + ClusterAuthentication: + 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. + type: object + properties: + kubelet: + description: |- + kubelet contains the configuration for kubelet on nodes. + This is where we can add granular control over kubelet fields. + +hyperfleet:write-mode=service-set + $ref: '#/components/schemas/KubeletConfig' + machineConfig: + description: |- + 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 + $ref: '#/components/schemas/MachineConfigSpec' + ClusterStatus: + description: ClusterStatus defines the observed state of a Cluster + type: object + properties: + apiEndpoint: + description: APIEndpoint is the cluster API server endpoint + type: string + conditions: + description: Conditions represent detailed cluster status + type: array + items: + type: object + consoleUrl: + description: ConsoleURL is the web console URL + type: string + provisionStartTime: + description: ProvisionStartTime is when provisioning began + type: object + readyTime: + description: ReadyTime is when the cluster became ready + type: object + state: + description: |- + State represents the high-level cluster state + +kubebuilder:validation:Enum=pending;provisioning;ready;degraded;deleting;failed + type: string + version: + description: Version is the observed OpenShift version + type: string + FeatureGateConfiguration: + type: object + FileSpec: + description: FileSpec represents a file to write to nodes. + type: object + required: + - path + properties: + contents: + description: contents is the file contents + type: string + group: + description: group is the file owner group + type: string + mode: + description: mode is the file permissions (e.g., 0644) + type: integer + format: int32 + overwrite: + description: overwrite specifies whether to overwrite existing files + type: boolean + path: + description: path is the absolute path where the file should be written + type: string + user: + description: user is the file owner user + type: string + HostedClusterSpecPassthrough: + description: HostedClusterSpecPassthrough mirrors HostedClusterSpec from upstream HyperShift + type: object + ImageConfiguration: + type: object + IngressConfiguration: + type: object + KubeletConfig: + description: |- + KubeletConfig specifies kubelet configuration. + This is a HyperFleet-owned type that mirrors hypershiftv1beta1.KubeletConfig + with granular markers for customer control. + type: object + properties: + containerLogMaxFiles: + description: |- + containerLogMaxFiles is the maximum number of container log files. + +hyperfleet:write-mode=mutable type: integer - description: Total number of nodepools - limit: + format: int32 + containerLogMaxSize: + description: |- + containerLogMaxSize is the maximum size of container log file before it is rotated. + +hyperfleet:write-mode=mutable + type: string + imageGCHighThresholdPercent: + description: |- + imageGCHighThresholdPercent is the disk usage percent triggering image GC. + +hyperfleet:write-mode=mutable type: integer - description: Number of items per page - offset: + format: int32 + imageGCLowThresholdPercent: + description: |- + imageGCLowThresholdPercent is the disk usage percent to gc to. + +hyperfleet:write-mode=mutable + type: integer + format: int32 + imageMinimumGCAge: + description: |- + imageMinimumGCAge is the minimum age for an unused image before it is garbage collected. + +hyperfleet:write-mode=mutable + type: object + kubeReserved: + description: |- + kubeReserved specifies resources reserved for Kubernetes system components. + +hyperfleet:write-mode=immutable + type: object + additionalProperties: + type: string + maxPods: + description: |- + maxPods is the maximum number of pods per node. + Customers can set this to optimize for high-density workloads. + +hyperfleet:write-mode=mutable + type: integer + format: int32 + podPidsLimit: + description: |- + podPidsLimit is the maximum number of PIDs allowed per pod. + Customers can increase this for applications that spawn many processes. + +hyperfleet:write-mode=mutable + type: integer + format: int64 + registryBurst: + description: |- + registryBurst is the maximum size of bursty pulls, temporarily allows pulls to burst. + +openshift:enable:FeatureGate=HyperFleetKubeletAdvanced + +hyperfleet:write-mode=mutable type: integer - description: Number of items skipped + format: int32 + registryPullQPS: + description: |- + registryPullQPS is the limit of registry pulls per second. + +openshift:enable:FeatureGate=HyperFleetKubeletAdvanced + +hyperfleet:write-mode=mutable + type: integer + format: int32 + serializeImagePulls: + description: |- + 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 + type: boolean + streamingConnectionIdleTimeout: + description: |- + streamingConnectionIdleTimeout is the maximum time a streaming connection can be idle. + +hyperfleet:write-mode=mutable + type: object + systemReserved: + description: |- + systemReserved specifies resources reserved for system daemons. + Customers can set this on cluster creation but cannot change it later. + +hyperfleet:write-mode=immutable + type: object + additionalProperties: + type: string + 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. + type: object + 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. + +openshift:enable:FeatureGate=HyperFleetMachineConfig + +hyperfleet:write-mode=immutable + type: array + items: + type: string + fips: + description: |- + fips enables FIPS mode on nodes. + Immutable - must be set at cluster creation, cannot be changed. + +hyperfleet:write-mode=immutable + type: boolean + NetworkConfiguration: + type: object + NodePoolSpecPassthrough: + description: NodePoolSpecPassthrough mirrors NodePoolSpec from upstream HyperShift + type: object + NodePoolStatus: + description: NodePoolStatus defines the observed state of a NodePool + type: object + properties: + availableReplicas: + description: AvailableReplicas is the number of available nodes + type: integer + format: int32 + conditions: + description: Conditions represent detailed node pool status + type: array + items: + type: object + readyReplicas: + description: ReadyReplicas is the number of ready nodes + type: integer + format: int32 + replicas: + description: Replicas is the current number of nodes + type: integer + format: int32 + state: + description: |- + State represents the high-level node pool state + +kubebuilder:validation:Enum=pending;scaling;ready;degraded;deleting;failed + type: string + OAuthConfiguration: + type: object + ProxyConfiguration: + type: object + SchedulerConfiguration: + type: object + SystemdDropin: + description: SystemdDropin represents a systemd drop-in configuration. + type: object + required: + - name + properties: + contents: + description: contents is the drop-in file contents + type: string + name: + description: name is the name of the drop-in file + type: string + SystemdUnit: + description: SystemdUnit represents a systemd unit configuration. + type: object + required: + - name + properties: + contents: + description: contents is the full systemd unit file contents + type: string + dropins: + description: dropins are drop-in configurations for the unit + type: array + items: + type: object + enabled: + description: enabled specifies whether the unit is enabled + type: boolean + name: + description: name is the name of the systemd unit (e.g., "custom.service") + type: string + ClusterSpec: + type: object + description: Cluster specification defining desired state + additionalProperties: true + properties: + deleteProtection: + description: |- + DeleteProtection prevents accidental deletion when enabled + +hyperfleet:write-mode=mutable + type: boolean + displayName: + description: |- + DisplayName is a human-readable name for the cluster + +hyperfleet:write-mode=mutable + +kubebuilder:validation:MaxLength=256 + type: string + expirationTimestamp: + description: |- + ExpirationTimestamp marks when this cluster should be automatically deleted + +hyperfleet:write-mode=mutable + type: object + hostedCluster: + description: |- + HostedCluster contains the full HyperShift HostedCluster configuration + All fields are generated from upstream and have safe defaults (hidden + service-set) + until explicitly reviewed and exposed + +kubebuilder:validation:Required + $ref: '#/components/schemas/HostedClusterSpecPassthrough' + properties: + description: |- + Properties are arbitrary key-value pairs for customer metadata + +hyperfleet:write-mode=mutable + type: object + additionalProperties: + type: string + tags: + description: |- + Tags are customer-defined labels for organizational purposes + This is a TechPreview feature + +hyperfleet:write-mode=mutable + +openshift:enable:FeatureGate=HyperFleetAutoScaling + type: object + additionalProperties: + type: string + cloudUrl: + type: string + readOnly: true + description: CloudFront URL with cluster ID (auto-populated by server) + example: https://doku78iof5s87.cloudfront.net/cluster-123 + placement: + type: string + description: Management cluster name (auto-populated if not provided) + example: management-cluster-us-east-1 responses: BadRequest: description: Bad request