From baa213efbcc1fcd4d6722a5011ef4e5313720366 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Tue, 19 May 2026 15:48:33 -0300 Subject: [PATCH] HYPERFLEET-1082 - feat: partner schema validation fail-fast and Helm support API now fails to start if the configured OpenAPI schema file is missing or invalid, catching misconfigurations before invalid data enters the database. Added Helm chart support for partner schemas via inline content or existing ConfigMap reference. --- Dockerfile | 1 + Makefile | 69 +++++++++++++++++++ README.md | 2 +- charts/templates/NOTES.txt | 21 ++++++ charts/templates/configmap.yaml | 1 + charts/templates/deployment.yaml | 23 +++++++ .../validation-schema-configmap.yaml | 16 +++++ charts/values.yaml | 39 +++++++++++ cmd/hyperfleet-api/server/routes.go | 27 +++----- docs/config.md | 3 + docs/deployment.md | 41 ++++++++++- openapi/README.md | 12 ++-- pkg/config/flags.go | 2 +- pkg/validators/schema_validator_test.go | 13 ++++ 14 files changed, 243 insertions(+), 27 deletions(-) create mode 100644 charts/templates/NOTES.txt create mode 100644 charts/templates/validation-schema-configmap.yaml diff --git a/Dockerfile b/Dockerfile index 226a0c64..0ac2019b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,7 @@ WORKDIR /app # ubi9-micro doesn't include CA certificates; copy from builder for TLS (e.g. Google Pub/Sub) COPY --from=builder /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem COPY --from=builder /build/bin/hyperfleet-api /app/hyperfleet-api +COPY --from=builder /build/openapi/openapi.yaml /app/openapi/openapi.yaml COPY --from=builder /build/LICENSE /licenses/LICENSE USER 65532:65532 diff --git a/Makefile b/Makefile index 0ceeab25..f81325c5 100755 --- a/Makefile +++ b/Makefile @@ -388,6 +388,75 @@ test-helm: ## Test Helm charts (lint, template, validate) --set-json 'adapters.nodepool=["validation","hypershift"]' > /dev/null @echo "Full adapter config template OK" @echo "" + @echo "Testing template with validation schema enabled..." + @OUTPUT=$$(helm template test-release charts/ \ + --set image.registry=quay.io \ + --set image.repository=openshift-hyperfleet/hyperfleet-api \ + --set image.tag=test \ + --set 'adapters.cluster=["validation"]' \ + --set 'adapters.nodepool=["validation"]' \ + --set validationSchema.enabled=true \ + --set-string 'validationSchema.content=openapi: 3.0.0'); \ + echo "$$OUTPUT" | grep -q 'app.kubernetes.io/component: validation-schema' || { echo "FAIL: validation-schema ConfigMap not found"; exit 1; }; \ + echo "$$OUTPUT" | grep -q '/etc/hyperfleet/validation-schema' || { echo "FAIL: validation schema volume mount not found"; exit 1; } + @echo "Validation schema enabled config template OK" + @echo "" + @echo "Testing template with validation schema disabled (default)..." + @OUTPUT=$$(helm template test-release charts/ \ + --set image.registry=quay.io \ + --set image.repository=openshift-hyperfleet/hyperfleet-api \ + --set image.tag=test \ + --set 'adapters.cluster=["validation"]' \ + --set 'adapters.nodepool=["validation"]'); \ + if echo "$$OUTPUT" | grep -q 'validation-schema'; then echo "FAIL: validation-schema should not appear when disabled"; exit 1; fi + @echo "Validation schema disabled config template OK" + @echo "" + @echo "Testing template with validation schema existingConfigMap..." + @OUTPUT=$$(helm template test-release charts/ \ + --set image.registry=quay.io \ + --set image.repository=openshift-hyperfleet/hyperfleet-api \ + --set image.tag=test \ + --set 'adapters.cluster=["validation"]' \ + --set 'adapters.nodepool=["validation"]' \ + --set validationSchema.enabled=true \ + --set validationSchema.existingConfigMap=my-validation-schema); \ + echo "$$OUTPUT" | grep -q 'my-validation-schema' || { echo "FAIL: existingConfigMap name not found"; exit 1; }; \ + if echo "$$OUTPUT" | grep -q 'app.kubernetes.io/component: validation-schema'; then echo "FAIL: generated ConfigMap should not appear with existingConfigMap"; exit 1; fi + @echo "Validation schema existingConfigMap config template OK" + @echo "" + @echo "Testing validation schema fails without content or existingConfigMap..." + @OUTPUT=$$(helm template test-release charts/ \ + --set image.registry=quay.io \ + --set image.repository=openshift-hyperfleet/hyperfleet-api \ + --set image.tag=test \ + --set 'adapters.cluster=["validation"]' \ + --set 'adapters.nodepool=["validation"]' \ + --set validationSchema.enabled=true 2>&1); \ + if [ $$? -eq 0 ]; then \ + echo "FAIL: should fail when validationSchema.enabled=true without content or existingConfigMap"; exit 1; \ + fi; \ + echo "$$OUTPUT" | grep -q 'validationSchema.content is required' || { \ + echo "FAIL: expected validationSchema validation error message"; echo "$$OUTPUT"; exit 1; \ + } + @echo "Validation schema validation (no content) OK" + @echo "" + @echo "Testing validation schema fails with whitespace-only content..." + @OUTPUT=$$(helm template test-release charts/ \ + --set image.registry=quay.io \ + --set image.repository=openshift-hyperfleet/hyperfleet-api \ + --set image.tag=test \ + --set 'adapters.cluster=["validation"]' \ + --set 'adapters.nodepool=["validation"]' \ + --set validationSchema.enabled=true \ + --set-string 'validationSchema.content= ' 2>&1); \ + if [ $$? -eq 0 ]; then \ + echo "FAIL: should fail when validationSchema.content is whitespace-only"; exit 1; \ + fi; \ + echo "$$OUTPUT" | grep -q 'validationSchema.content is required' || { \ + echo "FAIL: expected validationSchema validation error message"; echo "$$OUTPUT"; exit 1; \ + } + @echo "Validation schema validation (whitespace-only content) OK" + @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "All Helm chart tests passed!" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/README.md b/README.md index 848594a6..95d8cfd3 100755 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ This project uses [pre-commit](https://pre-commit.io/) for code quality checks. - **[Deployment](docs/deployment.md)** - Container images, Kubernetes deployment, and configuration - **[Authentication](docs/authentication.md)** - Development and production auth - **[Logging](docs/logging.md)** - Structured logging, OpenTelemetry integration, and data masking -- **[Partner Schema Validation](openapi/README.md#partner-schema-validation)** - How to supply a partner-specific OpenAPI schema for runtime `spec` field validation +- **[Validation Schema](openapi/README.md#validation-schema)** - How to supply a custom OpenAPI schema for runtime `spec` field validation ### Additional Resources diff --git a/charts/templates/NOTES.txt b/charts/templates/NOTES.txt new file mode 100644 index 00000000..e7747aa4 --- /dev/null +++ b/charts/templates/NOTES.txt @@ -0,0 +1,21 @@ +HyperFleet API has been deployed. + +Check deployment status: + kubectl get pods -l app.kubernetes.io/instance={{ .Release.Name }} + +Access the API: + kubectl port-forward svc/{{ include "hyperfleet-api.fullname" . }} {{ .Values.ports.api | default 8000 }}:{{ .Values.ports.api | default 8000 }} + +{{- if .Values.validationSchema.enabled }} + +Validation schema validation is ENABLED. +{{- if .Values.validationSchema.existingConfigMap }} + Schema source: ConfigMap "{{ .Values.validationSchema.existingConfigMap }}" +{{- else }} + Schema source: inline content (generated ConfigMap) +{{- end }} + The API will fail to start if the schema is missing or invalid. +{{- end }} + +Documentation: + https://github.com/openshift-hyperfleet/hyperfleet-api/blob/main/docs/deployment.md diff --git a/charts/templates/configmap.yaml b/charts/templates/configmap.yaml index 98280244..7b37626a 100644 --- a/charts/templates/configmap.yaml +++ b/charts/templates/configmap.yaml @@ -15,6 +15,7 @@ data: hostname: {{ .Values.config.server.hostname | quote }} host: {{ .Values.config.server.host | default "0.0.0.0" | quote }} port: {{ .Values.config.server.port | default 8000 }} + openapi_schema_path: {{ ternary "/etc/hyperfleet/validation-schema/openapi.yaml" "openapi/openapi.yaml" .Values.validationSchema.enabled }} timeouts: read: {{ .Values.config.server.timeouts.read }} diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index 5732d20f..4f40512b 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -23,6 +23,13 @@ spec: # Checksum of generated ConfigMap checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} {{- end }} + {{- if .Values.validationSchema.enabled }} + {{- if .Values.validationSchema.existingConfigMap }} + checksum/validation-schema: {{ (lookup "v1" "ConfigMap" .Release.Namespace .Values.validationSchema.existingConfigMap).data | toYaml | sha256sum }} + {{- else }} + checksum/validation-schema: {{ include (print $.Template.BasePath "/validation-schema-configmap.yaml") . | sha256sum }} + {{- end }} + {{- end }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} @@ -150,6 +157,12 @@ spec: mountPath: /etc/hyperfleet readOnly: true + {{- if .Values.validationSchema.enabled }} + - name: validation-schema + mountPath: /etc/hyperfleet/validation-schema + readOnly: true + {{- end }} + # Temp directory for writable filesystem - name: tmp mountPath: /tmp @@ -173,6 +186,16 @@ spec: - name: tmp emptyDir: {} + {{- if .Values.validationSchema.enabled }} + - name: validation-schema + configMap: + {{- if .Values.validationSchema.existingConfigMap }} + name: {{ .Values.validationSchema.existingConfigMap }} + {{- else }} + name: {{ include "hyperfleet-api.fullname" . }}-validation-schema + {{- end }} + {{- end }} + {{- with .Values.extraVolumes }} {{- toYaml . | nindent 6 }} {{- end }} diff --git a/charts/templates/validation-schema-configmap.yaml b/charts/templates/validation-schema-configmap.yaml new file mode 100644 index 00000000..62fda755 --- /dev/null +++ b/charts/templates/validation-schema-configmap.yaml @@ -0,0 +1,16 @@ +{{- if and .Values.validationSchema.enabled (not .Values.validationSchema.existingConfigMap) }} +{{- if not (trim (default "" .Values.validationSchema.content)) }} +{{- fail "validationSchema.content is required when validationSchema.enabled is true and validationSchema.existingConfigMap is not set" }} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "hyperfleet-api.fullname" . }}-validation-schema + labels: + {{- include "hyperfleet-api.labels" . | nindent 4 }} + app.kubernetes.io/component: validation-schema +data: + openapi.yaml: | +{{ .Values.validationSchema.content | indent 4 }} +{{- end }} diff --git a/charts/values.yaml b/charts/values.yaml index 1ae41b9c..38f5f183 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -360,6 +360,45 @@ sidecars: [] # cpu: 50m # memory: 64Mi +# ============================================================ +# Validation Schema Configuration +# ============================================================ +# Supply a custom OpenAPI schema for cluster/nodepool spec +# validation. When enabled, the schema content is mounted into +# the container and the API validates specs against it on every +# create/update request. The API will fail to start if the +# schema is invalid. +validationSchema: + enabled: false + # Use an existing ConfigMap instead of generating one from content. + # The ConfigMap must contain an "openapi.yaml" key with the schema. + # If set, validationSchema.content is ignored. + existingConfigMap: "" + # Inline OpenAPI 3.0 schema content. Must define ClusterSpec and + # NodePoolSpec under components.schemas. + # Example: + # content: | + # openapi: 3.0.0 + # info: + # title: My Validation Schema + # version: 1.0.0 + # paths: {} + # components: + # schemas: + # ClusterSpec: + # type: object + # required: [region] + # properties: + # region: + # type: string + # NodePoolSpec: + # type: object + # required: [machine_type] + # properties: + # machine_type: + # type: string + content: "" + # ============================================================ # Advanced Overrides (Escape Hatch) # ============================================================ diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index 31bc4dc3..4a35bbdd 100755 --- a/cmd/hyperfleet-api/server/routes.go +++ b/cmd/hyperfleet-api/server/routes.go @@ -96,7 +96,8 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router { apiV1Router.HandleFunc("/openapi.html", openapiHandler.GetOpenAPIUI).Methods(http.MethodGet) apiV1Router.HandleFunc("/openapi", openapiHandler.GetOpenAPI).Methods(http.MethodGet) - registerAPIMiddleware(apiV1Router) + err = registerAPIMiddleware(apiV1Router) + check(err, "Failed to initialize API middleware") // Auto-discovered routes (no manual editing needed) LoadDiscoveredRoutes(apiV1Router, services, authMiddleware) @@ -104,32 +105,20 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router { return mainRouter } -func registerAPIMiddleware(router *mux.Router) { +func registerAPIMiddleware(router *mux.Router) error { router.Use(MetricsMiddleware) - // Schema validation middleware (validates cluster/nodepool spec fields) - // Load schema path from config (follows Flag > Env > Config File > Default priority) schemaPath := env().Config.Server.OpenAPISchemaPath - - // Initialize schema validator (non-blocking - will warn if schema not found) - // Use background context for initialization logging ctx := context.Background() schemaValidator, err := validators.NewSchemaValidator(schemaPath) if err != nil { - // Log warning but don't fail - schema validation is optional - logger.With(ctx, logger.FieldSchemaPath, schemaPath).WithError(err).Warn("Failed to load schema validator") - logger.Warn(ctx, "Schema validation is disabled. Spec fields will not be validated.") - logger.Info(ctx, "To enable schema validation:") - logger.Info(ctx, " - Local: Run from repo root, or use --server-openapi-schema-path=openapi/openapi.yaml") - logger.Info(ctx, " - Config file: server.openapi_schema_path") - logger.Info(ctx, " - Environment: HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH") - } else { - // Apply schema validation middleware - logger.With(ctx, logger.FieldSchemaPath, schemaPath).Info("Schema validation enabled") - router.Use(middleware.SchemaValidationMiddleware(schemaValidator)) + return fmt.Errorf("schema validation required but failed to load from %s: %w", schemaPath, err) } + logger.With(ctx, logger.FieldSchemaPath, schemaPath).Info("Schema validation enabled") + router.Use(middleware.SchemaValidationMiddleware(schemaValidator)) + router.Use( func(next http.Handler) http.Handler { return db.TransactionMiddleware(next, env().Database.SessionFactory, env().Config.Database.Pool.RequestTimeout) @@ -137,4 +126,6 @@ func registerAPIMiddleware(router *mux.Router) { ) router.Use(gorillahandlers.CompressHandler) + + return nil } diff --git a/docs/config.md b/docs/config.md index bf66a15b..aff0003a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -248,6 +248,7 @@ HTTP server settings for the API endpoint. | `server.hostname` | string | `""` | Public hostname for logging (optional) | | `server.host` | string | `localhost` | Server bind host (`0.0.0.0` for Kubernetes) | | `server.port` | int | `8000` | Server bind port | +| `server.openapi_schema_path` | string | `openapi/openapi.yaml` | Path to OpenAPI schema for spec validation. API fails to start if missing or invalid. | | `server.timeouts.read` | duration | `5s` | HTTP read timeout | | `server.timeouts.write` | duration | `30s` | HTTP write timeout | | `server.tls.enabled` | bool | `false` | Enable HTTPS/TLS | @@ -341,6 +342,7 @@ Complete table of all configuration properties, their environment variables, and | `server.hostname` | `HYPERFLEET_SERVER_HOSTNAME` | string | `""` | | `server.host` | `HYPERFLEET_SERVER_HOST` | string | `localhost` | | `server.port` | `HYPERFLEET_SERVER_PORT` | int | `8000` | +| `server.openapi_schema_path` | `HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH` | string | `openapi/openapi.yaml` | | `server.timeouts.read` | `HYPERFLEET_SERVER_TIMEOUTS_READ` | duration | `5s` | | `server.timeouts.write` | `HYPERFLEET_SERVER_TIMEOUTS_WRITE` | duration | `30s` | | `server.tls.enabled` | `HYPERFLEET_SERVER_TLS_ENABLED` | bool | `false` | @@ -402,6 +404,7 @@ All CLI flags and their corresponding configuration paths. | `--server-hostname` | `server.hostname` | string | | `--server-host` | `server.host` | string | | `--server-port` | `server.port` | int | +| `--server-openapi-schema-path` | `server.openapi_schema_path` | string | | `--server-read-timeout` | `server.timeouts.read` | duration | | `--server-write-timeout` | `server.timeouts.write` | duration | | `--server-https-enabled` | `server.tls.enabled` | bool | diff --git a/docs/deployment.md b/docs/deployment.md index 1536e5be..9d4d2fa5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -145,7 +145,46 @@ HyperFleet API is configured via environment variables and configuration files. The API validates cluster and nodepool `spec` fields against an OpenAPI schema. This allows different providers (GCP, AWS, Azure) to have different spec structures. -Schema validation is optional and file-path based. Set `--server-openapi-schema-path` (or `HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH`) to a provider-specific OpenAPI schema file to enable it. If the path is missing or the file is unreadable, the API logs a warning and starts without validation — startup is non-blocking. +The schema path is configured via `--server-openapi-schema-path` (or `HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH`). The default is `openapi/openapi.yaml`. The API **will fail to start** if the configured schema file is missing, unreadable, or invalid — this ensures misconfigured deployments are caught immediately rather than silently accepting invalid data. + +#### Validation Schema via Helm + +Partners can supply a custom OpenAPI schema using the Helm chart: + +```yaml +validationSchema: + enabled: true + content: | + openapi: 3.0.0 + info: + title: My Validation Schema + version: 1.0.0 + paths: {} + components: + schemas: + ClusterSpec: + type: object + required: [region] + properties: + region: + type: string + NodePoolSpec: + type: object + required: [machine_type] + properties: + machine_type: + type: string +``` + +When `validationSchema.enabled` is `true`, the chart creates a ConfigMap with the schema content, mounts it into the container, and sets `server.openapi_schema_path` in the generated config file to point to it. + +Alternatively, reference an existing ConfigMap (must contain an `openapi.yaml` key): + +```yaml +validationSchema: + enabled: true + existingConfigMap: my-validation-schema +``` See [Configuration Guide](config.md) for all configuration options. diff --git a/openapi/README.md b/openapi/README.md index cc94c5c3..9705eb88 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -29,15 +29,15 @@ OpenAPI schemas are **not authored here**. They are defined in the [`hyperfleet- **Never edit `openapi.yaml` or `openapi.gen.go` directly.** Both are overwritten by `make generate`. -## Partner Schema Validation +## Validation Schema ### Why this exists -HyperFleet API is intentionally schema-agnostic at its core: it stores clusters and nodepools as long as the `spec` field is present and non-null, without caring what is inside it. This is by design — the API serves multiple partners with different provider-specific payloads. +HyperFleet API is intentionally schema-agnostic at its core: it stores clusters and nodepools as long as the `spec` field is present and non-null, without caring what is inside it. This is by design — the API serves multiple deployments with different provider-specific payloads. -Partners, however, **do** care. A GCP partner might require a `region` field inside `spec`; an AWS partner might require an `instanceType`. Without validation, invalid or incomplete specs silently end up in the database and only fail later when a downstream component tries to use them. +Deployers, however, **do** care. A GCP deployment might require a `region` field inside `spec`; an AWS deployment might require an `instanceType`. Without validation, invalid or incomplete specs silently end up in the database and only fail later when a downstream component tries to use them. -The `--server-openapi-schema-path` flag solves this: at deploy time, the operator points the API at a partner-specific OpenAPI schema file. The API then validates every `POST`/`PATCH` request's `spec` payload against that schema in HTTP middleware — before any service or database code runs. +The `--server-openapi-schema-path` flag solves this: at deploy time, the operator points the API at a deployment-specific OpenAPI schema file. The API then validates every `POST`/`PATCH` request's `spec` payload against that schema in HTTP middleware — before any service or database code runs. ### What the schema file must contain @@ -48,12 +48,12 @@ The schema file must be a valid OpenAPI 3.0 document. The API looks up two speci | `cluster` | `components.schemas.ClusterSpec` | | `nodepool` | `components.schemas.NodePoolSpec` | -A minimal example for a GCP partner: +A minimal example for a GCP deployment: ```yaml openapi: 3.0.0 info: - title: HyperFleet GCP Partner Schema + title: HyperFleet GCP Validation Schema version: 1.0.0 paths: {} components: diff --git a/pkg/config/flags.go b/pkg/config/flags.go index 0d5b5edf..8c30c9a9 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -22,7 +22,7 @@ func AddServerFlags(cmd *cobra.Command) { cmd.Flags().String("server-host", defaults.Host, "Server bind host") cmd.Flags().Int("server-port", defaults.Port, "Server bind port") cmd.Flags().String("server-openapi-schema-path", defaults.OpenAPISchemaPath, - "Path to OpenAPI schema for spec validation") + "Path to OpenAPI schema for spec validation (API will fail to start if file is missing or invalid)") cmd.Flags().Duration("server-read-timeout", defaults.Timeouts.Read, "HTTP server read timeout") cmd.Flags().Duration("server-write-timeout", defaults.Timeouts.Write, "HTTP server write timeout") cmd.Flags().String("server-https-cert-file", defaults.TLS.CertFile, "Path to TLS certificate file") diff --git a/pkg/validators/schema_validator_test.go b/pkg/validators/schema_validator_test.go index 07ca30c9..9dd4d1f0 100644 --- a/pkg/validators/schema_validator_test.go +++ b/pkg/validators/schema_validator_test.go @@ -85,6 +85,19 @@ func TestNewSchemaValidator_InvalidPath(t *testing.T) { Expect(err.Error()).To(ContainSubstring("failed to load OpenAPI schema")) } +func TestNewSchemaValidator_MalformedContent(t *testing.T) { + RegisterTestingT(t) + + tmpDir := t.TempDir() + schemaPath := filepath.Join(tmpDir, "bad-schema.yaml") + err := os.WriteFile(schemaPath, []byte("not: valid: openapi: {{{"), 0600) + Expect(err).To(BeNil()) + + _, err = NewSchemaValidator(schemaPath) + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to load OpenAPI schema")) +} + func TestNewSchemaValidator_MissingSchemas(t *testing.T) { RegisterTestingT(t)