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)