diff --git a/docs/guides/advanced-configuration.md b/docs/guides/advanced-configuration.md index 315ee2722..92de5aa07 100644 --- a/docs/guides/advanced-configuration.md +++ b/docs/guides/advanced-configuration.md @@ -117,6 +117,140 @@ cache) is set — never both — for both `database` and `cache`. --- +## Free-form service configuration + +The `ControlPlane` exposes the same free-form configuration escape hatch the +service CRs carry, at two levels. `spec.globalExtraConfig` applies to every +INI-configured service the control plane declares (Keystone and Glance today). +`spec.services..extraConfig` sets one service's own block. Both take the INI +`map[section][key] = value` shape the child renders into its service config +(`keystone.conf`, `glance-api.conf`). The dashboard is the exception: +`spec.services.horizon.extraConfig` is a flat map of Django settings, covered in +[Horizon settings are flat, not INI](#horizon-settings-are-flat-not-ini). + +```yaml +apiVersion: c5c3.io/v1alpha1 +kind: ControlPlane +metadata: + name: controlplane + namespace: openstack +spec: + openStackRelease: "2025.2" + # Applied to every INI service the control plane declares (Keystone, Glance): + globalExtraConfig: + database: + pool_timeout: "30" + services: + keystone: + extraConfig: + database: + pool_timeout: "60" # wins over the global value for Keystone + token: + expiration: "43200" # 12h instead of the default 1h + horizon: + extraConfig: + SESSION_TIMEOUT: 7200 # flat Django setting, not INI +``` + +The reconciler projects the merged INI result onto each INI service child's +`spec.extraConfig`, and the Horizon block verbatim onto the dashboard child. + +### Merge semantics + +For each INI service the global and per-service blocks merge **key by key**, the +per-service value winning. Whole sections are unioned: a per-service `[database]` +block that sets only `pool_timeout` still inherits every other `[database]` key +from the global block, and a global key with no per-service counterpart stays +effective. In the example above Keystone renders `[database] pool_timeout = 60` +(its own value), while a Glance service declared with no block of its own would +render `30`, inherited from the global block. + +The option catalog that guards a service CR's own `spec.extraConfig` guards the +merged result here too, so a global key must be valid against **every** declared +INI service's catalog. Because `spec.globalExtraConfig` reaches Glance as well, a +Keystone-only option placed there is rejected while `services.glance` is declared; +the fix is to move that key to `spec.services.keystone.extraConfig`. + +### Horizon settings are flat, not INI + +The dashboard renders `local_settings.py`, so `spec.services.horizon.extraConfig` +is a flat `map[setting] = value` of Django settings (`SESSION_TIMEOUT` above), +projected verbatim onto the Horizon child. `spec.globalExtraConfig` is INI and +never reaches the dashboard, and there is no merge for the Horizon block. + +### External keystone mode + +`spec.services.keystone.extraConfig` is forbidden when `services.keystone.mode` +is `External`: no Keystone workload is deployed, so there is nothing to render. +Both a CEL rule and the webhook reject it, with the message +`services.keystone.extraConfig is forbidden when services.keystone.mode is External`. +`spec.globalExtraConfig` stays legal but inert in External mode, the same posture +`spec.globalPolicyOverrides` holds, since no INI-configured workload consumes it. +Glance and Horizon are forbidden entirely in External mode, so their blocks +cannot appear at all. + +### Admission checks + +The validating webhook runs two families of check at `ControlPlane` admission, +using option catalogs and ownership registries embedded from the service API +packages. + +**Shape and ownership** run on every create and every update. Empty section or +key names in any INI block, and empty or non-Python-identifier setting names in +the Horizon block, are rejected. Keys the ControlPlane projects itself are +rejected outright: Glance's `[keystone_authtoken] password` (always), the Horizon +`SECRET_KEY` and every WebSSO / multi-domain setting (always, since the +ControlPlane projects those dynamically from the attached identity backends), and +Keystone's `[federation] trusted_dashboard` **only when** the ControlPlane derives +a dashboard endpoint from `services.horizon`. With no Horizon block that Keystone +key is admitted with a warning, so an externally-run dashboard can still do WebSSO +against the managed Keystone. Any other operator-owned key is honored but draws an +admission warning naming the key, its owner, any impact, and the block that set it. + +**Option-catalog** validation runs on every create, and on update only when a +catalog input changed: either INI block, `spec.openStackRelease`, +`services.keystone.image`, or a newly-declared service. A replicas bump alone does +not re-run it, so a stored CR whose `extraConfig` went stale-invalid against a +regenerated catalog is not rejected by an unrelated edit. The merged result per +INI service is checked against that service's per-release option catalog +(Keystone's resolved from `services.keystone.image.tag` when the image is +overridden, otherwise `spec.openStackRelease`; Glance's from +`spec.openStackRelease`). Unknown sections and options are rejected; a +deprecated-but-accepted option draws a warning naming its replacement. The check +**fails open** with one warning per service and no error when no catalog resolves: +a digest-pinned image, an unparseable tag, or a release the operator build ships +no catalog for. Plugin-registered INI sections are rejected as unknown, because +the ControlPlane has no plugins field and never sets `spec.plugins` on a child; +configure plugin sections on the service CR directly. Neither family has a CEL or +CRD-schema backstop; both live only in the webhook. + +Rejections name the block that carries the offending key. A finding is computed +once on the merged config, then attributed to every contributing path: +`spec.globalExtraConfig[
][]` and +`spec.services..extraConfig[
][]`. A key present in both blocks +yields one error per path. + +::: warning The projected children are operator-owned +The merged INI result and the Horizon block are re-asserted on the service +children on every reconcile. While no `ControlPlane` block is set the projection +carries no `extraConfig`, so a value set directly on a child stays untouched. Once +you set any `ControlPlane` block, the projection owns the child's field: a direct +edit on the child is reverted on the next reconcile. Clearing every `ControlPlane` +block projects nothing and the child's field reverts to unset. Configure the +free-form config on the `ControlPlane`, not on the child. +::: + +### Catalog skew across operator builds + +The catalogs consulted at `ControlPlane` admission are the ones embedded in the +**c5c3-operator** build. A deployed service operator of a different build may +embed a different catalog. The service CR's own validating webhook stays the +defense-in-depth check for that skew: when it rejects the projected child, the +ControlPlane surfaces `KeystoneProjectionRejected` or `GlanceProjectionRejected` +on its conditions, making a skewed rejection visible. + +--- + ## Feature pointer table Everything else the control plane supports. One-line hints, the ControlPlane knob @@ -134,7 +268,7 @@ full Keystone CR reference. | Database TLS/mTLS | `spec.database.tls` | `spec.infrastructure.database.tls` | [Enable Keystone Database TLS/mTLS](./keystone/enable-keystone-database-tls.md) | | Autoscaling (HPA) | `spec.autoscaling` | not exposed — standalone-only | [Autoscaling (HPA)](#autoscaling-hpa) | | Network policy | `spec.networkPolicy` | not exposed — standalone-only | [Network policy](#network-policy) | -| Free-form INI (`extraConfig`) | `spec.extraConfig` | not exposed — standalone-only | [ExtraConfig](#extraconfig-free-form-ini-sections) | +| Free-form config (`extraConfig`) | `spec.extraConfig` | `spec.services..extraConfig` (+ `spec.globalExtraConfig`) | [Free-form service configuration](#free-form-service-configuration) | | Scheduled admin-password rotation | `spec.passwordRotation` | not exposed — standalone-only | [Schedule Admin Password Rotation](./keystone/keystone-admin-password-scheduled-rotation.md) | | uWSGI tuning | `spec.uwsgi` | not exposed — standalone-only | [UWSGISpec](../reference/keystone/keystone-crd.md#uwsgispec) | | Logging | `spec.logging` | not exposed — standalone-only | [LoggingSpec](../reference/keystone/keystone-crd.md#loggingspec) | @@ -157,10 +291,12 @@ The "not exposed — standalone-only" knobs are not projectable through the On the [Quick Start](../quick-start.md) / [Quick Start (Extended)](../quick-start-extended.md) devstacks a standalone Keystone CR named `keystone` runs with no ControlPlane -projecting it. The recipes below apply to that CR. Several of them — -`spec.autoscaling`, `spec.networkPolicy`, `spec.extraConfig` — are **not exposed -on the `ControlPlane` CRD today**, so a standalone Keystone is the only place they -can be set. +projecting it. The recipes below apply to that CR. Two of them — +`spec.autoscaling` and `spec.networkPolicy` — are **not exposed on the +`ControlPlane` CRD today**, so a standalone Keystone is the only place they can be +set. `spec.extraConfig` is exposed on the ControlPlane, through +[Free-form service configuration](#free-form-service-configuration); the recipe +below is the standalone equivalent. ### Brownfield database @@ -306,11 +442,14 @@ for the auto-derived egress rules (Keystone API → MariaDB, Memcached, DNS). ### ExtraConfig — free-form INI sections -`spec.extraConfig` is not exposed on the `ControlPlane` CRD today, so it is -standalone-only. The typed fields on the CR cover the supported configuration -surface. For everything else — logging levels, oslo.messaging tuning, experimental -Keystone flags — `spec.extraConfig` takes a `map[section][key] = value` that is -rendered into the generated `keystone.conf`. +On a standalone Keystone CR you set `spec.extraConfig` directly. On a ControlPlane +the equivalent surface is `spec.globalExtraConfig` plus +`spec.services..extraConfig` (see +[Free-form service configuration](#free-form-service-configuration)). The typed +fields on the CR cover the supported configuration surface. For everything else — +logging levels, oslo.messaging tuning, experimental Keystone flags — +`spec.extraConfig` takes a `map[section][key] = value` that is rendered into the +generated `keystone.conf`. For the INI-file services (Keystone, Glance) the operator renders configuration through a single precedence chain: `plugins < operator defaults < diff --git a/docs/reference/c5c3/controlplane-crd.md b/docs/reference/c5c3/controlplane-crd.md index dceef345c..99c9fe31b 100644 --- a/docs/reference/c5c3/controlplane-crd.md +++ b/docs/reference/c5c3/controlplane-crd.md @@ -203,6 +203,7 @@ status: | `infrastructure` | [`*InfrastructureSpec`](#infrastructurespec) | Conditional | managed-mode defaulted | Shared backing services (database, cache) the control plane's services connect to. **Required** when `services.keystone.mode` is `Managed` (or unset, or `services.keystone` unset) — the defaulting webhook materializes a managed-mode `database`/`cache` when omitted, and the validating webhook rejects a non-External ControlPlane without it. **Forbidden** in **External** mode (an External ControlPlane provisions no backing services; phase 2 relaxes this to optional). The mode-conditional required/forbidden rule is webhook-enforced because CEL cannot span `spec.infrastructure` and `spec.services.keystone`; see [InfrastructureSpec](#infrastructurespec) and [Validation Rules](#validation-rules). | | `services` | [`ServicesSpec`](#servicesspec) | Yes | — | Per-service configuration projected into the individual service CRs. | | `globalPolicyOverrides` | [`*commonv1.PolicySpec`](../keystone/keystone-crd.md#policyspec) | No | `nil` | oslo.policy overrides applied across every service in the control plane. Per-service overrides (e.g. `services.keystone.policyOverrides`) take precedence over these global rules when both are set. | +| `globalExtraConfig` | `map[string]map[string]string` | No | `nil` | Free-form INI sections (`section` → `key` → `value`) applied to every INI-configured service the control plane declares (Keystone and Glance today). Merged **key by key** with each service's own `extraConfig`: sections are unioned, the per-service value wins per key, and a global key with no per-service counterpart stays effective, before the merged result is projected onto that service's child. **Never** applies to Horizon, which renders flat Django settings rather than INI. Legal but **inert** in External mode, the same posture as `globalPolicyOverrides`. Admission validates the merged result per declared INI service against that service's option catalog and operator-owned-key registry — see [ExtraConfig admission checks](#extraconfig-admission-checks). | | `secretStoreRef` | [`*commonv1.SecretStoreRefSpec`](#secretstorerefspec) | No | `nil` (defaults to the shared cluster store `openbao-cluster-store`) | Selects the External Secrets store the control plane routes its ExternalSecrets and backup PushSecrets through, and is **projected onto the Keystone and Horizon children** — so operators normally set the store here rather than on the individual service CRs. **Mutable:** switching stores is supported — the operator moves the fernet/credential key material in place, never re-creating it. When omitted, defaults to the shared cluster-scoped `ClusterSecretStore` named `openbao-cluster-store`, so existing deployments are unchanged; set `{kind: SecretStore, name: }` to reach OpenBao as a per-tenant identity resolved in the ControlPlane's own namespace. See [SecretStoreRefSpec](#secretstorerefspec). | | `korc` | [`KORCSpec`](#korcspec) | No | defaulted | K-ORC integration used to bootstrap and rotate the admin application credential and any declared bootstrap resources. Optional — the defaulting webhook fills `adminCredential` (cloudCredentialsRef, passwordSecretRef, applicationCredential restriction/rotation) from well-known defaults when omitted. | @@ -347,10 +348,11 @@ managed-vs-brownfield split of the infrastructure specs at the service level: | `mode: External` | Service-less: identity is managed against a pre-existing, externally-operated Keystone at [`external.authURL`](#externalkeystonespec) and no Keystone workload is deployed. | In **External** mode every managed-only field below (`replicas`, `image`, -`policyOverrides`, `rotationInterval`, `gateway`, `publicEndpoint`) is -**forbidden** and the typed [`external`](#externalkeystonespec) block is -**required**. These intra-struct rules are enforced by type-level CEL -`XValidation` rules (so they hold at the CRD schema layer even when the +`policyOverrides`, `extraConfig`, `rotationInterval`, `gateway`, +`publicEndpoint`) is **forbidden** and the typed +[`external`](#externalkeystonespec) block is **required**. These intra-struct +rules are enforced by type-level CEL `XValidation` rules (so they hold at the +CRD schema layer even when the validating webhook is bypassed) and mirrored by the validating webhook; see [Validation Rules](#validation-rules). @@ -361,6 +363,7 @@ validating webhook is bypassed) and mirrored by the validating webhook; see | `replicas` | `*int32` | No | `nil` (Keystone operator default, 3) | Overrides the number of Keystone API replicas. When `nil`, the reconciler leaves `replicas` unset on the projected Keystone CR, so the Keystone operator applies its own default. Minimum: 1. **Forbidden in External mode.** | | `image` | [`*commonv1.ImageSpec`](../keystone/keystone-crd.md#imagespec) | No | `nil` | Overrides the Keystone container image. When `nil`, the reconciler derives the image as `ghcr.io/c5c3/keystone:{spec.openStackRelease}`. When set, the whole image reference is used verbatim. | | `policyOverrides` | [`*commonv1.PolicySpec`](../keystone/keystone-crd.md#policyspec) | No | `nil` | Per-service oslo.policy overrides for Keystone. When set, these take precedence over `spec.globalPolicyOverrides` for the Keystone service. | +| `extraConfig` | `map[string]map[string]string` | No | `nil` | Free-form INI sections for the Keystone service. Merged **key by key** with `spec.globalExtraConfig` (this per-service value winning per key) and the merged result projected onto the Keystone child's `spec.extraConfig`. **Forbidden in External mode** (CEL + webhook, message `services.keystone.extraConfig is forbidden when services.keystone.mode is External`): no Keystone workload is deployed, so there is no config to render. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block — see [ExtraConfig admission checks](#extraconfig-admission-checks). | | `rotationInterval` | `*metav1.Duration` | No | `nil` | Overrides the Fernet / credential-key rotation interval the reconciler derives for the projected Keystone CR. When `nil`, the reconciler derives a default schedule. When set, the duration is converted to a cron expression and applied to both `fernet.rotationSchedule` and `credentialKeys.rotationSchedule` on the projected Keystone CR. An unconvertible interval (not a positive whole number of days) is **rejected at admission** by the validating webhook; if the webhook is bypassed, the reconciler surfaces `KeystoneReady=False` with reason `InvalidRotationInterval` and returns the error so the reconcile chain stops and requeues with backoff. | | `gateway` | [`*commonv1.GatewaySpec`](#gatewayspec) | No | `nil` | Exposes the projected Keystone API externally via a Gateway API HTTPRoute. When `nil`, no HTTPRoute is projected and the Keystone API is reachable in-cluster only (its ClusterIP Service). When set, the reconciler projects it onto the Keystone CR's `spec.gateway`, so the Keystone operator attaches an HTTPRoute to the referenced Gateway. When a `gateway` is set its `hostname` must be non-empty — enforced at admission by the validating webhook (see [Validation Rules](#validation-rules)). | | `publicEndpoint` | `string` | No | `""` | Externally routable Keystone identity endpoint URL (e.g. `https://keystone.example.com/v3`). Projected into the Keystone bootstrap (`--bootstrap-public-url`) and used for the K-ORC identity catalog Endpoint, so external clients resolve the same URL Keystone advertises. When set, it must be an HTTP(S) URL (`+kubebuilder:validation:Pattern=^https?://`), so a malformed endpoint fails at admission rather than wedging the projected Keystone CR. When empty and `gateway` is set, the reconciler derives `https://{gateway.hostname}/v3` (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like `:8443`). | @@ -389,6 +392,7 @@ its fields carry per-field External-mode forbid-rules. | `gateway` | [`*commonv1.GatewaySpec`](#gatewayspec) | No | `nil` | Exposes the projected dashboard externally via a Gateway API HTTPRoute. When `nil` the dashboard is reachable in-cluster only. | | `secretKeyRef` | [`*commonv1.SecretRefSpec`](../keystone/keystone-crd.md#secretrefspec) | No | `nil` | Overrides the Secret holding the Django `SECRET_KEY` the dashboard replicas share. When `nil` the reconciler defaults to the kind-infrastructure shim Secret `horizon-secret-key`, which is pinned to the **default** ControlPlane identity — multi-ControlPlane deployments MUST set this explicitly. | | `publicEndpoint` | `string` | No | `""` | The **browser-observed** dashboard base URL, without a trailing slash and **including a non-default port** (e.g. `https://horizon.example.com:8443`). The reconciler derives the WebSSO origin from it (`publicEndpoint + "/auth/websso/"`) and projects that onto the Keystone child's `spec.federation.trustedDashboards`. Keystone matches the origin the dashboard sends verbatim, so the value must reproduce exactly what the browser's address bar shows. When empty and `gateway` is set, the reconciler derives `https://{gateway.hostname}` (the default-443 form). Must match `^https?://`, parse with a host, and be at most 499 characters — the Keystone child's 512-character bound on `trustedDashboards[]` minus the 13 characters `/auth/websso/` appends. | +| `extraConfig` | `map[string]JSON` | No | `nil` | Free-form **flat Django settings** mirroring the Horizon child's `spec.extraConfig`: keys are Django setting names, values arbitrary JSON. Projected **verbatim** (deep-copied) onto the Horizon child. Because the dashboard renders `local_settings.py` rather than INI, `spec.globalExtraConfig` never applies and there is no merge for this block. Admission rejects empty or non-Python-identifier setting names and the operator-owned settings — `SECRET_KEY` and every WebSSO / multi-domain setting — see [ExtraConfig admission checks](#extraconfig-admission-checks). | | `dedicatedBackingServices` | [`*HorizonDedicatedBackingServicesSpec`](#dedicatedbackingservices) | No | `nil` (shares the ControlPlane-wide cache) | Opts the dashboard **out** of the shared `spec.infrastructure.cache` and gives it a cache of its own. The dashboard consumes no database, so `cache` is the only class it can take dedicated. | | `namespace` | [`*ServiceNamespaceSpec`](#service-namespaces) | No | `nil` (placed in the ControlPlane's namespace) | Places the dashboard — and the cache and secret store that follow it — in a namespace of its own. Create-only. A dashboard placed apart reads its `SECRET_KEY` from **that** namespace: the default `horizon-secret-key` shim Secret is namespace-local, so supply the key material there (and name it via `secretKeyRef`). See [Service Namespaces](#service-namespaces). | @@ -427,6 +431,7 @@ carry per-field External-mode forbid-rules. | `publicEndpoint` | `string` | No | `""` | Externally routable Glance image endpoint URL (e.g. `https://glance.127-0-0-1.nip.io:8443`). Used **only** for the K-ORC public image catalog Endpoint; it is projected into no child CR, so the validating webhook is the only gate on it. When set, it must match `^https?://`, parse to a bare origin with a host (no path, query, or fragment — the Glance API is served at the root), and be at most 512 characters. When a `gateway` is configured the scheme must be `https` and the host must equal `gateway.hostname` (the port may differ) — see [Validation Rules](#validation-rules). When empty and `gateway` is set, the reconciler derives `https://{gateway.hostname}` (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like `:8443`). | | `backends` | [`[]GlanceBackendEntry`](#glancebackendentry) | Yes | — | The curated list of image stores, projected one-to-one into [`GlanceBackend`](../glance/glance-backend-crd.md) child CRs. **Exactly one** entry must set `isDefault`, promoting its store to the Glance `default_backend`. A `listType=map` list keyed on `name` (the API server rejects duplicate names); `MinItems` 1 and `MaxItems` 32 (every entry amplifies into one `GlanceBackend` CR). The single-default invariant holds at the CRD schema layer via a CEL `XValidation` rule and is mirrored by the validating webhook. | | `databaseCredentialsMode` | `string` (`Static` \| `Dynamic`) | No | `""` (inherits `spec.infrastructure.database.credentialsMode`) | Per-service override of the ControlPlane-wide credentials mode for the managed **shared** database, so a staged migration can run Glance on one mode while another service (e.g. Keystone) stays on the other. Empty (the default) **inherits** the shared mode — deliberately **not** materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A `Dynamic` override is **rejected** when Glance declares a [dedicated](#dedicatedbackingservices) database (dedicated is `Static`-only — set `dedicatedBackingServices.database.credentialsMode` instead; see [Credential modes](#credential-modes)) and when the shared database is **brownfield** (`clusterRef` unset); `Static` is always admitted. | +| `extraConfig` | `map[string]map[string]string` | No | `nil` | Free-form INI sections for the Glance service. Merged **key by key** with `spec.globalExtraConfig` (this per-service value winning per key) and the merged result projected onto the Glance child's `spec.extraConfig`. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block; the sole always-rejected owned key is `[keystone_authtoken] password` — see [ExtraConfig admission checks](#extraconfig-admission-checks). | | `dedicatedBackingServices` | [`*GlanceDedicatedBackingServicesSpec`](#dedicatedbackingservices) | No | `nil` (shares the ControlPlane-wide instances) | Opts Glance **out** of the shared `spec.infrastructure` instances and gives it a database and/or cache of its own. Glance consumes both classes, so it can take either or both dedicated. | | `namespace` | [`*ServiceNamespaceSpec`](#service-namespaces) | No | `nil` (placed in the ControlPlane's namespace) | Places the Glance service — and the database, cache, secret store, and object-store credential material that follow it — in a namespace of its own. Create-only. See [Service Namespaces](#service-namespaces). | @@ -1294,7 +1299,7 @@ Keystone discipline: | `spec.globalPolicyOverrides`, `spec.services.keystone.policyOverrides` (CEL) | `!has(self.rules) \|\| self.rules.all(k, size(self.rules[k]) > 0)` → "policy rule value must not be empty" | | `spec.services.keystone` (CEL) | `mode == 'External'` ⇒ `has(self.external)` → "external is required when services.keystone.mode is External" | | `spec.services.keystone` (CEL) | `has(self.external)` ⇒ `mode == 'External'` → "external may only be set when services.keystone.mode is External" | -| `spec.services.keystone` (CEL) | `mode == 'External'` ⇒ each managed-only field (`replicas`, `image`, `policyOverrides`, `rotationInterval`, `gateway`, `publicEndpoint`, `federationProxyImage`, `databaseCredentialsMode`) absent → "services.keystone.\ is forbidden when services.keystone.mode is External" (one rule per field) | +| `spec.services.keystone` (CEL) | `mode == 'External'` ⇒ each managed-only field (`replicas`, `image`, `policyOverrides`, `extraConfig`, `rotationInterval`, `gateway`, `publicEndpoint`, `federationProxyImage`, `databaseCredentialsMode`) absent → "services.keystone.\ is forbidden when services.keystone.mode is External" (one rule per field) | | `spec.services.glance.replicas` | Minimum: 1 | | `spec.services.glance.databaseCredentialsMode` | Enum: `Static`, `Dynamic` | | `spec.services.glance.gateway.hostname` | MinLength 1 (shared `GatewaySpec` marker) | @@ -1329,7 +1334,7 @@ short-circuit on the first error. | External block required | `spec.services.keystone.external` | `field.Required` | `mode: External` but `external` unset. Defense-in-depth mirror of the CEL rule. | | External authURL required/URL | `spec.services.keystone.external.authURL` | `field.Required` / `field.Invalid` | In External mode, `authURL` empty (Required), or not matching `^https?://[^\s/]+` / failing a full `net/url` parse / exceeding 2048 characters (Invalid). Mirrors the CRD required/pattern/maxLength markers. | | External caBundle name required | `spec.services.keystone.external.caBundleSecretRef.name` | `field.Required` | `caBundleSecretRef` set with an empty `name`. Mirrors the shared `SecretRefSpec` MinLength marker. | -| Managed-only field forbidden in External mode | `spec.services.keystone.{replicas,image,policyOverrides,rotationInterval,gateway,publicEndpoint,federationProxyImage}` | `field.Forbidden` | The field is set while `mode: External`. Defense-in-depth mirror of the per-field CEL rules. | +| Managed-only field forbidden in External mode | `spec.services.keystone.{replicas,image,policyOverrides,extraConfig,rotationInterval,gateway,publicEndpoint,federationProxyImage}` | `field.Forbidden` | The field is set while `mode: External`. Defense-in-depth mirror of the per-field CEL rules. | | Keystone credentials-mode override forbidden in External mode | `spec.services.keystone.databaseCredentialsMode` | `field.Forbidden` | The per-service override is set while `mode: External` — no managed database is provisioned, so there is no credentials mode to override. Defense-in-depth mirror of the per-field CEL rule. | | Dynamic credentials-mode override on a dedicated database | `spec.services.{keystone,glance}.databaseCredentialsMode` | `field.Forbidden` | The override is `Dynamic` while that service declares a dedicated database: the override retargets the shared database the service does not use, and a dedicated database is `Static`-only (set `dedicatedBackingServices.database.credentialsMode` instead). `Static` stays admitted. **Cross-field, webhook-only.** | | Dynamic credentials-mode override on a brownfield shared database | `spec.services.{keystone,glance}.databaseCredentialsMode` | `field.Forbidden` | The override is `Dynamic` while the shared database is brownfield (`clusterRef` unset): the dynamic engine issues per-tenant DB users only against a cluster the operator provisions. **Cross-field, webhook-only.** | @@ -1362,6 +1367,64 @@ short-circuit on the first error. | Glance service-account target namespace | `spec.korc.serviceAccounts[].targetNamespace` (the `glance` entry) | `field.Invalid` | The `glance` account's `targetNamespace` must equal the namespace Glance is placed in when Glance has a dedicated namespace, and be empty or the ControlPlane's own namespace when Glance is co-located — its consumer credentials Secret rides that namespace's tenant store. **Cross-field, webhook-only.** | | Glance forbidden in External mode | `spec.services.glance` | `field.Forbidden` | `services.glance` set while `mode: External` (Glance needs its own External-mode design). **Cross-field, webhook-only.** | +### ExtraConfig admission checks + +`spec.globalExtraConfig` and the per-service `services..extraConfig` blocks +are checked at admission by the validating webhook only. There is **no CEL or +CRD-schema backstop** (beyond the External-mode forbid rule on +`services.keystone.extraConfig` above), so a cluster that bypasses the webhook +accepts a misspelled option and surfaces it at render time. The checks run on the +**merged** result — `spec.globalExtraConfig` unioned with each service's block, +the per-service value winning per key — which is the same map the reconciler +projects, so admission and projection never disagree. Findings are computed once +on the merged map and attributed back to every block that carries the offending +`(section, key)`: an error anchored at `spec.globalExtraConfig[
][]` +and/or `spec.services..extraConfig[
][]`. A key present in both +blocks yields one error per path. Because `spec.globalExtraConfig` reaches every +declared INI service, a Keystone-only option placed there is rejected while +`services.glance` is declared; the fix is to move it to +`services.keystone.extraConfig`. + +Two families run, with different gating: + +- **Shape and ownership** — un-gated, on every create **and** every update, since + it depends on nothing a regenerated catalog can invalidate. It rejects empty + section or key names in any INI block, and empty or non-Python-identifier + (`[A-Za-z_][A-Za-z0-9_]*`) setting names in the Horizon block. It rejects the + operator-owned keys the ControlPlane projects itself: Glance's + `[keystone_authtoken] password` (always), Horizon's `SECRET_KEY` and every + WebSSO / multi-domain setting (always — the ControlPlane projects `websso` / + `multiDomain` dynamically from the attached identity backends, so a collision is + not decidable at admission), and Keystone's `[federation] trusted_dashboard` + **only when** the ControlPlane derives a dashboard endpoint from + `services.horizon`. With no Horizon block that Keystone key is admitted with a + warning, supporting an externally-run dashboard doing WebSSO against the managed + Keystone. Every other operator-owned key is honored but produces an admission + warning naming the key, its owner, its impact, and the contributing block + path(s). +- **Option catalog** — on every create, and on update **only when a catalog input + changed**: either INI block, `spec.openStackRelease`, `services.keystone.image`, + or a newly-declared service. So a stored CR whose `extraConfig` went + stale-invalid against a regenerated catalog is not rejected by an unrelated edit + such as a replicas bump. The merged block of each declared INI service is + validated against that service's per-release option catalog embedded in the + operator (Keystone's release resolved from `services.keystone.image.tag` when + the image override is set, otherwise `spec.openStackRelease`; Glance's from + `spec.openStackRelease`). An unknown section or option is rejected with the + section and key named; a deprecated-but-accepted option is admitted with a + warning naming its replacement. Plugin-registered sections are rejected as + unknown, because the ControlPlane has no `plugins` field and never sets + `spec.plugins` on a child; configure plugin sections on the service CR directly. + The check **fails open** with exactly one warning per service (and no error) + when no catalog resolves: a digest-pinned image, an unparseable tag, or a + release the operator build ships no catalog for. + +The catalogs consulted here are the ones embedded in the **c5c3-operator** build. +A deployed service operator of a different build may embed a different catalog; +the child service webhook remains the defense-in-depth check for that skew, and a +skewed rejection surfaces as `KeystoneProjectionRejected` / +`GlanceProjectionRejected` on the ControlPlane's conditions. + ### Update-only immutability rules On **update** the validating webhook additionally rejects changes to the diff --git a/docs/reference/c5c3/controlplane-reconciler.md b/docs/reference/c5c3/controlplane-reconciler.md index e773ae18a..b0a81d6ed 100644 --- a/docs/reference/c5c3/controlplane-reconciler.md +++ b/docs/reference/c5c3/controlplane-reconciler.md @@ -975,7 +975,7 @@ the ControlPlane provisioned: `spec.services.keystone.federationProxyImage` override when set, else `ghcr.io/c5c3/keystone-federation-proxy:latest`; `spec.federation.trustedDashboards` is the ControlPlane's own dashboard origin - (`horizonPublicEndpoint(cp) + "/auth/websso/"`), or `nil` when no dashboard is + (`cp.Spec.Services.Horizon.DerivedPublicEndpoint() + "/auth/websso/"`), or `nil` when no dashboard is externally reachable. Both are assigned unconditionally, so clearing the override or the horizon block reverts the child. The origin is derived **top-down from `cp.Spec`**, never from the Horizon child's status, so this diff --git a/internal/common/go.mod b/internal/common/go.mod index ce9299e9c..6ac6df6b5 100644 --- a/internal/common/go.mod +++ b/internal/common/go.mod @@ -31,7 +31,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect @@ -70,7 +70,7 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect diff --git a/internal/common/go.sum b/internal/common/go.sum index 32afa7068..29e9b82e1 100644 --- a/internal/common/go.sum +++ b/internal/common/go.sum @@ -24,8 +24,7 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= @@ -77,8 +76,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/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/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -106,18 +105,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -146,26 +139,24 @@ 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.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/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go b/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go new file mode 100644 index 000000000..dc1259864 --- /dev/null +++ b/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "fmt" + "reflect" + "strings" + + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/c5c3/forge/internal/common/config" + "github.com/c5c3/forge/internal/common/release" + glancev1alpha1 "github.com/c5c3/forge/operators/glance/api/v1alpha1" + horizonv1alpha1 "github.com/c5c3/forge/operators/horizon/api/v1alpha1" + keystonev1alpha1 "github.com/c5c3/forge/operators/keystone/api/v1alpha1" +) + +// MergedExtraConfig merges the ControlPlane-wide globalExtraConfig with one +// service's own extraConfig and returns the effective INI block projected onto +// that service's child CR. Sections are unioned, the per-service value wins per +// key, and a global key with no per-service counterpart stays effective. A +// merge that produces no sections is normalized to nil, so an empty merge +// projects an absent field rather than an empty map. +// +// This is the SINGLE merge function both the reconciler and the validating +// webhook consume: validating one merge while projecting another is exactly the +// drift this helper exists to prevent, so admission and reconciliation always +// agree on the effective config. +func MergedExtraConfig(global, service map[string]map[string]string) map[string]map[string]string { + // config.MergeDefaults(userConfig, defaults) merges key by key with + // userConfig winning, into a fresh map without mutating either input, so the + // per-service block is the userConfig and the global block is the defaults. + // It never returns nil, hence the length-zero normalization below. + merged := config.MergeDefaults(service, global) + if len(merged) == 0 { + return nil + } + return merged +} + +// DerivedPublicEndpoint returns the public URL the ControlPlane will derive for +// the dashboard. It is shared by the identity-backend projection and the +// admission-time trusted-dashboard check so both derive the gate identically. +// +// An explicit publicEndpoint wins (with any trailing slash trimmed); otherwise, +// when a gateway is set, it is derived as "https://{gateway.hostname}" (the +// default-443 form). A nil receiver (services.horizon unset) and the +// neither-set case both yield "". +func (s *ServiceHorizonSpec) DerivedPublicEndpoint() string { + if s == nil { + return "" + } + if s.PublicEndpoint != "" { + return strings.TrimRight(s.PublicEndpoint, "/") + } + if s.Gateway != nil && s.Gateway.Hostname != "" { + return "https://" + s.Gateway.Hostname + } + return "" +} + +// iniBlock pairs a free-form INI extraConfig map with the spec field path it +// lives at, so a finding on the MERGED config can be attributed back to the +// concrete block (globalExtraConfig or the per-service block) that contributed +// the offending (section, key). Provenance is by membership: a block contributes +// a pair exactly when block[section][key] is present. +type iniBlock struct { + config map[string]map[string]string + path *field.Path +} + +// contributingKeyPaths returns the field paths of the blocks that carry +// (section, key), each anchored at the real leaf (e.g. +// spec.globalExtraConfig[federation][trusted_dashboard]). A pair present in two +// blocks yields two paths, one per block. +func contributingKeyPaths(section, key string, blocks ...iniBlock) []*field.Path { + var paths []*field.Path + for _, b := range blocks { + if _, ok := b.config[section][key]; ok { + paths = append(paths, b.path.Key(section).Key(key)) + } + } + return paths +} + +// joinPaths renders a slice of field paths for an admission-warning message. +func joinPaths(paths []*field.Path) string { + parts := make([]string, len(paths)) + for i, p := range paths { + parts[i] = p.String() + } + return strings.Join(parts, ", ") +} + +// validateExtraConfigOwnership runs the shape and ownership checks (Family A) on +// every create and every update, UN-gated: unlike the option-catalog checks it +// depends on nothing a regenerated catalog can invalidate, so a stale-valid CR +// is never at risk. It rejects malformed blocks (empty section/key/setting +// names, non-Python-identifier Horizon settings), forbids overrides of the +// operator-owned keys the ControlPlane projects, and warns on honored-but-owned +// overrides — attributing every finding to the concrete block(s) that carry it. +func validateExtraConfigOwnership(cp *ControlPlane) (admission.Warnings, field.ErrorList) { + var warnings admission.Warnings + var errs field.ErrorList + + specPath := field.NewPath("spec") + globalPath := specPath.Child("globalExtraConfig") + keystonePath := specPath.Child("services", "keystone", "extraConfig") + glancePath := specPath.Child("services", "glance", "extraConfig") + horizonPath := specPath.Child("services", "horizon", "extraConfig") + + // --- Shape checks ----------------------------------------------------- + errs = append(errs, validateINIShape(globalPath, cp.Spec.GlobalExtraConfig)...) + if ks := cp.Spec.Services.Keystone; ks != nil { + errs = append(errs, validateINIShape(keystonePath, ks.ExtraConfig)...) + } + if gl := cp.Spec.Services.Glance; gl != nil { + errs = append(errs, validateINIShape(glancePath, gl.ExtraConfig)...) + } + if hz := cp.Spec.Services.Horizon; hz != nil { + for name := range hz.ExtraConfig { + if name == "" { + errs = append(errs, field.Invalid(horizonPath, name, + "extraConfig setting name must not be empty")) + continue + } + if !horizonv1alpha1.PythonSettingName.MatchString(name) { + errs = append(errs, field.Invalid(horizonPath.Key(name), name, + "extraConfig setting name must be a valid Python identifier ([A-Za-z_][A-Za-z0-9_]*)")) + } + } + } + + // --- Keystone merged-result ownership --------------------------------- + // Skipped in External mode: no Keystone workload is deployed, so there is no + // config the merged block would project (the extraConfig field itself is + // rejected by validateKeystoneMode there). + if ks := cp.Spec.Services.Keystone; ks != nil && !cp.IsExternalKeystone() { + blocks := []iniBlock{{cp.Spec.GlobalExtraConfig, globalPath}, {ks.ExtraConfig, keystonePath}} + merged := MergedExtraConfig(cp.Spec.GlobalExtraConfig, ks.ExtraConfig) + for _, owned := range config.FindOwnedOverrides(merged, keystonev1alpha1.OwnedConfigKeys) { + paths := contributingKeyPaths(owned.Section, owned.Key, blocks...) + // The sole Rejected keystone key is [federation] trusted_dashboard. + // The ControlPlane owns it only when it derives a dashboard endpoint + // from services.horizon; when none is derived, an externally-run + // dashboard doing WebSSO against the managed Keystone is legitimate, so + // the override is honored-but-reported rather than forbidden. + if owned.Rejected && cp.Spec.Services.Horizon.DerivedPublicEndpoint() != "" { + for _, p := range paths { + errs = append(errs, field.Forbidden(p, rejectedKeystoneMessage(owned))) + } + continue + } + warnings = append(warnings, ownedINIWarning(owned, paths)) + } + } + + // --- Glance merged-result ownership ----------------------------------- + if gl := cp.Spec.Services.Glance; gl != nil { + blocks := []iniBlock{{cp.Spec.GlobalExtraConfig, globalPath}, {gl.ExtraConfig, glancePath}} + merged := MergedExtraConfig(cp.Spec.GlobalExtraConfig, gl.ExtraConfig) + for _, owned := range config.FindOwnedOverrides(merged, glancev1alpha1.OwnedConfigKeys) { + paths := contributingKeyPaths(owned.Section, owned.Key, blocks...) + // The sole Rejected glance key ([keystone_authtoken] password) is always + // forbidden: rendering it would leak the service password into the + // namespace-readable ConfigMap. + if owned.Rejected { + for _, p := range paths { + errs = append(errs, field.Forbidden(p, rejectedGlanceMessage(owned))) + } + continue + } + warnings = append(warnings, ownedINIWarning(owned, paths)) + } + } + + // --- Horizon ownership (flat Django settings) ------------------------- + if hz := cp.Spec.Services.Horizon; hz != nil && len(hz.ExtraConfig) > 0 { + names := make([]string, 0, len(hz.ExtraConfig)) + for name := range hz.ExtraConfig { + names = append(names, name) + } + for _, owned := range config.FindOwnedSettingOverrides(names, horizonv1alpha1.OwnedConfigKeys) { + settingPath := horizonPath.Key(owned.Key) + // Every Rejected Horizon setting (SECRET_KEY and the websso / + // multi-domain settings) is forbidden UNCONDITIONALLY — stricter than + // the child webhook, which gates the websso/multiDomain collisions on + // the typed blocks being present. The ControlPlane projects + // services.websso / services.multiDomain dynamically from the attached + // identity backends, so whether a collision materializes is not + // decidable at admission; admitting the key would plant a runtime + // HorizonProjectionRejected wedge. + if owned.Rejected { + errs = append(errs, field.Forbidden(settingPath, rejectedHorizonMessage(owned))) + continue + } + warnings = append(warnings, ownedSettingWarning(owned, settingPath)) + } + } + + return warnings, errs +} + +// validateINIShape rejects an empty section name or an empty option key in one +// free-form INI block, mirroring the glance child's messages so a rendered +// service config never carries a nameless [
] or a bare "= value" line. +// +// It also rejects a newline or carriage return in any section name, key, OR +// value. RenderINIMulti writes each verbatim ("[%s]" for the section, "%s = %s" +// per option), so such a character injects arbitrary additional config lines — +// smuggling a whole [section]/key past the (section, key)-name-keyed ownership +// (FindOwnedOverrides) and catalog (FindUnknownOptions) gates, which inspect map +// structure only and never look inside a value. A value like +// "false\n[federation]\ntrusted_dashboard = https://attacker" would otherwise +// render a real [federation] section the gates never saw. +func validateINIShape(blockPath *field.Path, block map[string]map[string]string) field.ErrorList { + var errs field.ErrorList + for section, opts := range block { + if section == "" { + errs = append(errs, field.Invalid(blockPath, section, + "extraConfig section name must not be empty")) + continue + } + if strings.ContainsAny(section, "\n\r") { + errs = append(errs, field.Invalid(blockPath, section, + "extraConfig section name must not contain a newline or carriage return")) + continue + } + for key, value := range opts { + if key == "" { + errs = append(errs, field.Invalid(blockPath.Key(section), key, + "extraConfig key must not be empty")) + continue + } + if strings.ContainsAny(key, "\n\r") || strings.ContainsAny(value, "\n\r") { + errs = append(errs, field.Invalid(blockPath.Key(section).Key(key), key, + "extraConfig key and value must not contain a newline or carriage return: "+ + "the rendered INI writes them verbatim, so a newline injects arbitrary config lines")) + } + } + } + return errs +} + +// rejectedKeystoneMessage renders the Forbidden detail for the merged Keystone +// [federation] trusted_dashboard override, which the ControlPlane owns via its +// Horizon-derived trusted-dashboards projection. +func rejectedKeystoneMessage(owned config.OwnedKey) string { + return fmt.Sprintf("[%s] %s is managed by the ControlPlane's Horizon-derived trusted-dashboards "+ + "projection and must not be set in extraConfig (the merged value wins and would silently drop the "+ + "projected dashboard origins)", owned.Section, owned.Key) +} + +// rejectedGlanceMessage mirrors the glance child's Forbidden message for a +// Rejected owned key, including its Impact. +func rejectedGlanceMessage(owned config.OwnedKey) string { + msg := fmt.Sprintf("%s is managed via %s and must not be set in extraConfig", owned.Key, owned.OwnedBy) + if owned.Impact != "" { + msg += fmt.Sprintf(" (%s)", owned.Impact) + } + return msg +} + +// rejectedHorizonMessage renders the Forbidden detail for a Rejected Horizon +// setting: SECRET_KEY is managed via services.horizon.secretKeyRef, and the +// websso / multi-domain settings are owned by the identity-backend projection. +func rejectedHorizonMessage(owned config.OwnedKey) string { + if owned.Key == "SECRET_KEY" { + return "SECRET_KEY is managed via services.horizon.secretKeyRef and must not be set in extraConfig" + } + return fmt.Sprintf("%s is owned by the ControlPlane's identity-backend projection and must not be set in "+ + "extraConfig (services.websso and services.multiDomain are projected dynamically from the attached "+ + "identity backends, so the override cannot be reconciled)", owned.Key) +} + +// ownedINIWarning renders the admission warning for a honored-but-owned INI +// override, naming the section/key, the OwnedBy source, the Impact when set, and +// the contributing block path(s). +func ownedINIWarning(owned config.OwnedKey, paths []*field.Path) string { + msg := fmt.Sprintf("[%s] %s overrides an operator-owned key (managed by %s)", + owned.Section, owned.Key, owned.OwnedBy) + if owned.Impact != "" { + msg += fmt.Sprintf(" (%s)", owned.Impact) + } + return msg + fmt.Sprintf("; the override is honored but reported (set at %s)", joinPaths(paths)) +} + +// ownedSettingWarning renders the admission warning for a honored-but-owned +// flat Horizon setting. +func ownedSettingWarning(owned config.OwnedKey, path *field.Path) string { + msg := fmt.Sprintf("%s overrides an operator-owned Django setting (managed by %s)", owned.Key, owned.OwnedBy) + if owned.Impact != "" { + msg += fmt.Sprintf(" (%s)", owned.Impact) + } + return msg + fmt.Sprintf("; the override is honored but reported (set at %s)", path.String()) +} + +// validateExtraConfigCatalogs validates the MERGED INI config of each declared +// INI service (Keystone unless External, Glance) against the option catalog +// embedded for the resolved release (Family B). It fails open — exactly one +// warning, no error — when no catalog resolves for a non-empty merged config, so +// a digest pin, an unparseable tag, or a release the build ships no catalog for +// never blocks admission. Every unknown option/section becomes a field error +// attributed to the concrete block(s) that carry it; every deprecated-but- +// accepted option becomes a warning. +func validateExtraConfigCatalogs(cp *ControlPlane) (admission.Warnings, field.ErrorList) { + var warnings admission.Warnings + var errs field.ErrorList + + specPath := field.NewPath("spec") + globalPath := specPath.Child("globalExtraConfig") + + // --- Keystone --------------------------------------------------------- + if ks := cp.Spec.Services.Keystone; ks != nil && !cp.IsExternalKeystone() { + merged := MergedExtraConfig(cp.Spec.GlobalExtraConfig, ks.ExtraConfig) + // Mirror reconcile_keystone.go: the release tag is + // services.keystone.image.Tag when the image is overridden, else + // spec.openStackRelease. + tag := cp.Spec.OpenStackRelease + releaseField := "spec.openStackRelease" + if ks.Image != nil { + tag = ks.Image.Tag + releaseField = "services.keystone.image" + } + catalog, ok := keystonev1alpha1.OptionCatalogForRelease(tag) + if !ok { + if w := failOpenCatalogWarning("keystone", releaseField, tag, merged); w != "" { + warnings = append(warnings, w) + } + } else { + // No plugin sections: the projection never sets spec.plugins on the + // child. Only the operator-owned keys are exempt. + ex := config.CatalogExemptions{Keys: config.KeyExemptionsFromRegistry(keystonev1alpha1.OwnedConfigKeys)} + w, e := attributeCatalogFindings("keystone", catalog, merged, ex, + iniBlock{cp.Spec.GlobalExtraConfig, globalPath}, + iniBlock{ks.ExtraConfig, specPath.Child("services", "keystone", "extraConfig")}) + warnings = append(warnings, w...) + errs = append(errs, e...) + } + } + + // --- Glance ----------------------------------------------------------- + if gl := cp.Spec.Services.Glance; gl != nil { + merged := MergedExtraConfig(cp.Spec.GlobalExtraConfig, gl.ExtraConfig) + catalog, ok := glancev1alpha1.OptionCatalogForRelease(cp.Spec.OpenStackRelease) + if !ok { + if w := failOpenCatalogWarning("glance", "spec.openStackRelease", cp.Spec.OpenStackRelease, merged); w != "" { + warnings = append(warnings, w) + } + } else { + sections := make(map[string]struct{}, len(glancev1alpha1.CatalogExemptSections)) + for _, s := range glancev1alpha1.CatalogExemptSections { + sections[s] = struct{}{} + } + ex := config.CatalogExemptions{ + Sections: sections, + Keys: config.KeyExemptionsFromRegistry(glancev1alpha1.OwnedConfigKeys), + } + w, e := attributeCatalogFindings("glance", catalog, merged, ex, + iniBlock{cp.Spec.GlobalExtraConfig, globalPath}, + iniBlock{gl.ExtraConfig, specPath.Child("services", "glance", "extraConfig")}) + warnings = append(warnings, w...) + errs = append(errs, e...) + } + } + + return warnings, errs +} + +// failOpenCatalogWarning renders the single fail-open warning for a service +// whose merged config could not be validated against a catalog. It distinguishes +// a release input that does not parse as a release (empty tag, digest pin, +// "latest") from a parseable release the build ships no catalog for, mirroring +// the keystone child. It returns "" when the merged config is empty (nothing to +// validate, so no warning). +func failOpenCatalogWarning(svc, releaseField, tag string, merged map[string]map[string]string) string { + if len(merged) == 0 { + return "" + } + rel, err := release.ParseRelease(tag) + if err != nil { + return fmt.Sprintf("the merged %s extraConfig was not validated against an option catalog: "+ + "%s does not name an OpenStack release", svc, releaseField) + } + return fmt.Sprintf("the merged %s extraConfig was not validated against an option catalog: "+ + "no catalog for release %q is embedded in this operator build", + svc, fmt.Sprintf("%d.%d", rel.Year, rel.Minor)) +} + +// attributeCatalogFindings runs the unknown-option scan ONCE on the merged map, +// then attributes each finding to every block that carries it — anchoring the +// field error/warning at the real leaf. A pair present in both the global and +// the per-service block yields one error per block. +func attributeCatalogFindings(svc string, catalog *config.OptionCatalog, merged map[string]map[string]string, ex config.CatalogExemptions, blocks ...iniBlock) (admission.Warnings, field.ErrorList) { + unknown, deprecated := catalog.FindUnknownOptions(merged, ex) + base := catalog.Release + + var errs field.ErrorList + for _, u := range unknown { + for _, b := range blocks { + if _, ok := b.config[u.Section][u.Key]; !ok { + continue + } + fldPath := b.path.Key(u.Section).Key(u.Key) + if u.SectionUnknown { + errs = append(errs, field.Invalid(fldPath, u.Key, + fmt.Sprintf("no such section in the %s %s option catalog "+ + "(plugin-registered sections are not configurable through the ControlPlane)", svc, base))) + continue + } + errs = append(errs, field.Invalid(fldPath, u.Key, + fmt.Sprintf("no such option in the %s %s option catalog", svc, base))) + } + } + + var warnings admission.Warnings + for _, d := range deprecated { + for _, b := range blocks { + if _, ok := b.config[d.Section][d.Key]; !ok { + continue + } + if d.Replacement == "" { + warnings = append(warnings, fmt.Sprintf( + "%s [%s] %s: deprecated option in %s %s with no replacement", + b.path.String(), d.Section, d.Key, svc, base, + )) + continue + } + warnings = append(warnings, fmt.Sprintf( + "%s [%s] %s: deprecated option in %s %s, replaced by %s", + b.path.String(), d.Section, d.Key, svc, base, d.Replacement, + )) + } + } + return warnings, errs +} + +// controlPlaneExtraConfigCatalogInputsChanged reports whether anything the +// option-catalog check (Family B) depends on differs between oldObj and newObj: +// the global INI block, either per-service INI block (its presence and its +// extraConfig), the release, or the Keystone image override that selects the +// Keystone catalog. ValidateUpdate gates the catalog re-validation on this so a +// CR whose extraConfig went stale-invalid is not rejected by an otherwise- +// unrelated update. It mirrors the keystone child's extraConfigCatalogInputsChanged. +func controlPlaneExtraConfigCatalogInputsChanged(oldObj, newObj *ControlPlane) bool { + if !reflect.DeepEqual(oldObj.Spec.GlobalExtraConfig, newObj.Spec.GlobalExtraConfig) { + return true + } + if oldObj.Spec.OpenStackRelease != newObj.Spec.OpenStackRelease { + return true + } + + oldKs, newKs := oldObj.Spec.Services.Keystone, newObj.Spec.Services.Keystone + if (oldKs == nil) != (newKs == nil) { + return true + } + if oldKs != nil && newKs != nil { + if !reflect.DeepEqual(oldKs.ExtraConfig, newKs.ExtraConfig) { + return true + } + if !reflect.DeepEqual(oldKs.Image, newKs.Image) { + return true + } + } + + oldGl, newGl := oldObj.Spec.Services.Glance, newObj.Spec.Services.Glance + if (oldGl == nil) != (newGl == nil) { + return true + } + if oldGl != nil && newGl != nil { + if !reflect.DeepEqual(oldGl.ExtraConfig, newGl.ExtraConfig) { + return true + } + } + + return false +} diff --git a/operators/c5c3/api/v1alpha1/controlplane_extraconfig_test.go b/operators/c5c3/api/v1alpha1/controlplane_extraconfig_test.go new file mode 100644 index 000000000..d16a0ef92 --- /dev/null +++ b/operators/c5c3/api/v1alpha1/controlplane_extraconfig_test.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "testing" + + . "github.com/onsi/gomega" + + commonv1 "github.com/c5c3/forge/internal/common/types" +) + +// TestMergedExtraConfig_PerServiceWinsPerKey verifies the merge semantics: a key +// present in both blocks yields the per-service value, global-only keys in the +// same section survive, and global-only sections survive. +func TestMergedExtraConfig_PerServiceWinsPerKey(t *testing.T) { + g := NewGomegaWithT(t) + + global := map[string]map[string]string{ + "DEFAULT": { + "debug": "false", + "transport_url": "rabbit://global", + }, + "oslo_messaging": { + "driver": "messagingv2", + }, + } + service := map[string]map[string]string{ + "DEFAULT": { + "debug": "true", + }, + } + + merged := MergedExtraConfig(global, service) + + // Per-service value wins the shared key. + g.Expect(merged["DEFAULT"]["debug"]).To(Equal("true")) + // Global-only key in a shared section survives. + g.Expect(merged["DEFAULT"]["transport_url"]).To(Equal("rabbit://global")) + // Global-only section survives. + g.Expect(merged["oslo_messaging"]["driver"]).To(Equal("messagingv2")) +} + +// TestMergedExtraConfig_NilWhenBothNil verifies two nil inputs normalize to a +// nil result rather than an empty map, so an absent field is projected. +func TestMergedExtraConfig_NilWhenBothNil(t *testing.T) { + g := NewGomegaWithT(t) + + g.Expect(MergedExtraConfig(nil, nil)).To(BeNil()) +} + +// TestMergedExtraConfig_NilWhenBothEmpty verifies two empty (non-nil) maps +// normalize to nil, guarding the length-zero normalization against an empty +// merge result config.MergeDefaults would otherwise return. +func TestMergedExtraConfig_NilWhenBothEmpty(t *testing.T) { + g := NewGomegaWithT(t) + + merged := MergedExtraConfig( + map[string]map[string]string{}, + map[string]map[string]string{}, + ) + g.Expect(merged).To(BeNil()) +} + +// TestMergedExtraConfig_ReturnsIndependentCopy verifies mutating the returned +// map never writes back through to the single input that was set, exercising +// both the global-only and the service-only paths. +func TestMergedExtraConfig_ReturnsIndependentCopy(t *testing.T) { + g := NewGomegaWithT(t) + + t.Run("only global set", func(t *testing.T) { + global := map[string]map[string]string{ + "DEFAULT": {"debug": "false"}, + } + merged := MergedExtraConfig(global, nil) + + // Add a key to an existing section and a whole new section. + merged["DEFAULT"]["extra"] = "1" + merged["new_section"] = map[string]string{"k": "v"} + + g.Expect(global["DEFAULT"]).To(HaveLen(1), + "mutating the merged map must not add a key to the global input") + g.Expect(global).NotTo(HaveKey("new_section"), + "mutating the merged map must not add a section to the global input") + }) + + t.Run("only service set", func(t *testing.T) { + service := map[string]map[string]string{ + "DEFAULT": {"debug": "true"}, + } + merged := MergedExtraConfig(nil, service) + + merged["DEFAULT"]["extra"] = "1" + merged["new_section"] = map[string]string{"k": "v"} + + g.Expect(service["DEFAULT"]).To(HaveLen(1), + "mutating the merged map must not add a key to the service input") + g.Expect(service).NotTo(HaveKey("new_section"), + "mutating the merged map must not add a section to the service input") + }) +} + +// TestDerivedPublicEndpoint pins the shared derivation used by both the +// identity-backend projection and the admission-time trusted-dashboard check. +func TestDerivedPublicEndpoint(t *testing.T) { + g := NewGomegaWithT(t) + + for _, tc := range []struct { + name string + hz *ServiceHorizonSpec + want string + }{ + { + name: "nil receiver", + hz: nil, + want: "", + }, + { + name: "explicit publicEndpoint with trailing slash is trimmed", + hz: &ServiceHorizonSpec{PublicEndpoint: "https://horizon.example.com:8443/"}, + want: "https://horizon.example.com:8443", + }, + { + name: "explicit publicEndpoint without slash is verbatim", + hz: &ServiceHorizonSpec{PublicEndpoint: "https://horizon.example.com"}, + want: "https://horizon.example.com", + }, + { + name: "gateway hostname derives the default-443 form", + hz: &ServiceHorizonSpec{Gateway: &commonv1.GatewaySpec{Hostname: "horizon.127-0-0-1.nip.io"}}, + want: "https://horizon.127-0-0-1.nip.io", + }, + { + name: "neither set yields empty", + hz: &ServiceHorizonSpec{}, + want: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + g.Expect(tc.hz.DerivedPublicEndpoint()).To(Equal(tc.want)) + }) + } +} diff --git a/operators/c5c3/api/v1alpha1/controlplane_types.go b/operators/c5c3/api/v1alpha1/controlplane_types.go index 5cd294324..98b515988 100644 --- a/operators/c5c3/api/v1alpha1/controlplane_types.go +++ b/operators/c5c3/api/v1alpha1/controlplane_types.go @@ -5,6 +5,7 @@ package v1alpha1 import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" commonv1 "github.com/c5c3/forge/internal/common/types" @@ -109,6 +110,18 @@ type ControlPlaneSpec struct { // +optional GlobalPolicyOverrides *commonv1.PolicySpec `json:"globalPolicyOverrides,omitempty"` + // GlobalExtraConfig is a free-form INI block applied to every INI-configured + // service the ControlPlane declares (Keystone and Glance today). It is merged + // key by key with each service's own extraConfig — sections are unioned, the + // per-service value wins per key, and a global key with no per-service + // counterpart stays effective — before the merged result is projected onto + // the child CR. It NEVER applies to Horizon: the dashboard renders flat Django + // settings, not INI, so services.horizon.extraConfig stands alone. Legal but + // inert in External mode (no INI-configured workload is deployed), the same + // posture globalPolicyOverrides has. + // +optional + GlobalExtraConfig map[string]map[string]string `json:"globalExtraConfig,omitempty"` + // KORC configures the K-ORC (OpenStack Resource Controller) integration used // to bootstrap and rotate the admin application credential and any declared // bootstrap resources. @@ -180,11 +193,11 @@ type ServicesSpec struct { // dependency coordinates below). // // DECISION (plan decision #3 — L2 dependency coordinates): the L1 api -// package imports ONLY commonv1, k8s.io/apimachinery/*, k8s.io/api/core/v1, and -// sigs.k8s.io/controller-runtime/* (all already in go.mod). `go mod tidy` -// therefore prunes any service-module require because nothing here imports -// them. The L2 reconciler will need these coordinates (recorded here so the -// orchestrator does not have to re-resolve them): +// package originally imported ONLY commonv1, k8s.io/apimachinery/*, +// k8s.io/api/core/v1, and sigs.k8s.io/controller-runtime/* (all already in +// go.mod). `go mod tidy` therefore pruned any service-module require because +// nothing here imported them. The L2 reconciler will need these coordinates +// (recorded here so the orchestrator does not have to re-resolve them): // - keystone => ../keystone (local replace directive) // - mariadb-operator => github.com/mariadb-operator/mariadb-operator v0.38.1 // - external-secrets => github.com/external-secrets/external-secrets/apis @@ -192,6 +205,18 @@ type ServicesSpec struct { // - K-ORC => github.com/k-orc/openstack-resource-controller/v2 v2.5.0 // - memcached.c5c3.io => NO public Go module; L2 uses unstructured.Unstructured // +// DECISION AMENDED (admission-time extraConfig validation): the api package now +// ADDITIONALLY imports the three service api packages — +// operators/keystone/api/v1alpha1, operators/glance/api/v1alpha1, and +// operators/horizon/api/v1alpha1 — to reach their embedded option catalogs and +// ownership registries, so the merged extraConfig is validated at admission +// against the same catalogs the reconciler projects. This gives up only +// package-level import purity, not a new module dependency: all three modules +// have been direct requires in operators/c5c3/go.mod since the L2 reconciler +// landed, so `go mod tidy` no longer prunes them. Reaching the catalogs in +// place keeps them single-source rather than duplicating them into the api +// package. +// // Mode is the Managed|External discriminator (default Managed). In Managed mode // (or unset) the reconciler projects a full Keystone service exactly as before. // In External mode the ControlPlane manages identity against a pre-existing, @@ -215,6 +240,7 @@ type ServicesSpec struct { // +kubebuilder:validation:XValidation:rule="!(has(self.mode) && self.mode == 'External') || !has(self.dedicatedBackingServices)",message="services.keystone.dedicatedBackingServices is forbidden when services.keystone.mode is External" // +kubebuilder:validation:XValidation:rule="!(has(self.mode) && self.mode == 'External') || !has(self.namespace)",message="services.keystone.namespace is forbidden when services.keystone.mode is External" // +kubebuilder:validation:XValidation:rule="!(has(self.mode) && self.mode == 'External') || !has(self.databaseCredentialsMode)",message="services.keystone.databaseCredentialsMode is forbidden when services.keystone.mode is External" +// +kubebuilder:validation:XValidation:rule="!(has(self.mode) && self.mode == 'External') || !has(self.extraConfig)",message="services.keystone.extraConfig is forbidden when services.keystone.mode is External" type ServiceKeystoneSpec struct { // Mode selects whether the Keystone service is Managed (the reconciler // deploys and owns a full Keystone workload, today's behavior) or External @@ -249,6 +275,14 @@ type ServiceKeystoneSpec struct { // +optional PolicyOverrides *commonv1.PolicySpec `json:"policyOverrides,omitempty"` + // ExtraConfig is a free-form INI block for the Keystone service. It is merged + // key by key with spec.globalExtraConfig — sections unioned, this per-service + // value winning per key — and the merged result is projected onto the Keystone + // child's spec.extraConfig. Forbidden in External mode (CEL + webhook + // enforced): no Keystone workload is deployed, so there is no config to render. + // +optional + ExtraConfig map[string]map[string]string `json:"extraConfig,omitempty"` + // RotationInterval optionally overrides the Fernet key rotation interval the // reconciler derives for the projected Keystone CR. When nil the reconciler // derives a default schedule. @@ -727,6 +761,15 @@ type ServiceHorizonSpec struct { // +kubebuilder:validation:Pattern=`^https?://` PublicEndpoint string `json:"publicEndpoint,omitempty"` + // ExtraConfig carries free-form flat Django settings, mirroring the Horizon + // child's spec.extraConfig (operators/horizon/api/v1alpha1/horizon_types.go). + // Keys are Django setting names and values are arbitrary JSON; the reconciler + // projects the block verbatim onto the Horizon child. It is NOT an INI block, + // so spec.globalExtraConfig — which is INI, applied only to the INI-configured + // services — never applies to it. + // +optional + ExtraConfig map[string]apiextensionsv1.JSON `json:"extraConfig,omitempty"` + // DedicatedBackingServices opts the dashboard OUT of the ControlPlane-wide // shared cache declared in spec.infrastructure and gives it a cache of its // own. Omitting it (the default) keeps today's behavior — the dashboard shares @@ -837,6 +880,13 @@ type ServiceGlanceSpec struct { // +kubebuilder:validation:Enum=Static;Dynamic DatabaseCredentialsMode string `json:"databaseCredentialsMode,omitempty"` + // ExtraConfig is a free-form INI block for the Glance service. It is merged + // key by key with spec.globalExtraConfig — sections unioned, this per-service + // value winning per key — and the merged result is projected onto the Glance + // child's spec.extraConfig. + // +optional + ExtraConfig map[string]map[string]string `json:"extraConfig,omitempty"` + // DedicatedBackingServices opts the Glance service out of the ControlPlane-wide // shared instances declared in spec.infrastructure and gives it backing // services of its own. Omitting it (the default) keeps Glance on the diff --git a/operators/c5c3/api/v1alpha1/controlplane_webhook.go b/operators/c5c3/api/v1alpha1/controlplane_webhook.go index 5746a9083..4d25f182b 100644 --- a/operators/c5c3/api/v1alpha1/controlplane_webhook.go +++ b/operators/c5c3/api/v1alpha1/controlplane_webhook.go @@ -218,7 +218,7 @@ func validateHorizonPublicEndpoint(specPath *field.Path, hz *ServiceHorizonSpec) var errs field.ErrorList // A single trailing slash is the one path the reconciler tolerates: - // horizonPublicEndpoint trims it before appending "/auth/websso/". + // DerivedPublicEndpoint trims it before appending "/auth/websso/". if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { errs = append(errs, field.Invalid(pePath, hz.PublicEndpoint, "must be a bare origin (scheme://host[:port]) with no path, query, or fragment: the WebSSO origin is "+ @@ -1198,7 +1198,20 @@ func (w *ControlPlaneWebhook) Default(_ context.Context, obj *ControlPlane) erro // Reviewer: please verify boundary 6 = option (a). func (w *ControlPlaneWebhook) ValidateCreate(ctx context.Context, obj *ControlPlane) (admission.Warnings, error) { warnings := insecurePublicEndpointWarnings(obj) - if err := newInvalidIfErrs(obj, w.validate(obj)); err != nil { + + // extraConfig admission checks: the un-gated shape/ownership family (A) and + // the option-catalog family (B). Both fold their errors into the single + // Invalid response alongside validate()'s, mirroring how the keystone child + // folds its catalog errors in. + ownershipWarnings, ownershipErrs := validateExtraConfigOwnership(obj) + catalogWarnings, catalogErrs := validateExtraConfigCatalogs(obj) + warnings = append(warnings, ownershipWarnings...) + warnings = append(warnings, catalogWarnings...) + + allErrs := w.validate(obj) + allErrs = append(allErrs, ownershipErrs...) + allErrs = append(allErrs, catalogErrs...) + if err := newInvalidIfErrs(obj, allErrs); err != nil { return warnings, err } if err := w.validateUniqueInNamespace(ctx, obj); err != nil { @@ -1221,10 +1234,30 @@ func (w *ControlPlaneWebhook) ValidateCreate(ctx context.Context, obj *ControlPl // downgrade error are accumulated into a single Invalid response so a reviewer // sees all problems at once. func (w *ControlPlaneWebhook) ValidateUpdate(_ context.Context, oldObj, newObj *ControlPlane) (admission.Warnings, error) { + warnings := insecurePublicEndpointWarnings(newObj) + allErrs := w.validate(newObj) allErrs = append(allErrs, validateImmutable(oldObj, newObj)...) allErrs = append(allErrs, validateReleaseNotDowngraded(oldObj, newObj)...) - return insecurePublicEndpointWarnings(newObj), newInvalidIfErrs(newObj, allErrs) + + // Family A (shape/ownership) always re-runs: it depends on nothing a + // regenerated catalog can invalidate, and a newly-derived Horizon endpoint + // can turn a previously-admitted trusted_dashboard override into a rejection. + ownershipWarnings, ownershipErrs := validateExtraConfigOwnership(newObj) + warnings = append(warnings, ownershipWarnings...) + allErrs = append(allErrs, ownershipErrs...) + + // Family B (option catalog) re-runs only when one of its inputs changed, so + // an unrelated update (e.g. scaling replicas) never retroactively rejects a + // CR whose extraConfig was accepted at create time but has since been + // invalidated by a regenerated catalog. + if controlPlaneExtraConfigCatalogInputsChanged(oldObj, newObj) { + catalogWarnings, catalogErrs := validateExtraConfigCatalogs(newObj) + warnings = append(warnings, catalogWarnings...) + allErrs = append(allErrs, catalogErrs...) + } + + return warnings, newInvalidIfErrs(newObj, allErrs) } // ValidateDelete implements admission.Validator[*ControlPlane]. The method is @@ -1896,6 +1929,10 @@ func validateKeystoneMode(cp *ControlPlane) field.ErrorList { allErrs = append(allErrs, field.Forbidden(ksPath.Child("databaseCredentialsMode"), "forbidden when services.keystone.mode is External (no managed database is provisioned, so there is no credentials mode to override)")) } + if ks.ExtraConfig != nil { + allErrs = append(allErrs, field.Forbidden(ksPath.Child("extraConfig"), + "forbidden when services.keystone.mode is External (no Keystone workload is deployed, so there is no config to render)")) + } // Cross-field rules CEL cannot express. if cp.Spec.Infrastructure != nil { diff --git a/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go b/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go index 1b71c0e3c..2913021fc 100644 --- a/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go +++ b/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go @@ -13,6 +13,7 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -2056,7 +2057,7 @@ func TestValidateCreate_HorizonPublicEndpointMustBeABareOrigin(t *testing.T) { // TestValidateCreate_AcceptsHorizonPublicEndpointWithPort pins the case the // override exists for: a dashboard published off the default HTTPS port. The -// trailing-slash form is accepted too — horizonPublicEndpoint trims it before +// trailing-slash form is accepted too — DerivedPublicEndpoint trims it before // appending the WebSSO path. func TestValidateCreate_AcceptsHorizonPublicEndpointWithPort(t *testing.T) { for _, endpoint := range []string{ @@ -4244,3 +4245,544 @@ func TestValidateCreate_AcceptsCredentialsModeOverridesOnManagedShared(t *testin }) } } + +// --- extraConfig admission checks (Family A ownership/shape, Family B catalog) --- + +// jsonSetting is a small helper for a Horizon extraConfig JSON value. +func jsonSetting(raw string) apiextensionsv1.JSON { + return apiextensionsv1.JSON{Raw: []byte(raw)} +} + +// TestValidateCreate_RejectsUnknownGlobalExtraConfigOption pins that a global INI +// option the Keystone catalog does not accept is rejected at the real +// globalExtraConfig leaf, naming the keystone catalog and its release. +func TestValidateCreate_RejectsUnknownGlobalExtraConfigOption(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"token": {"providr": "fernet"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[token][providr]")) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2025.2 option catalog")) +} + +// TestValidateCreate_RejectsGlobalKeystoneOptionUnknownToGlance pins the +// cross-service reach of globalExtraConfig: [token] expiration is a valid +// Keystone option but Glance has no [token] section, so declaring Glance rejects +// the global block against the GLANCE catalog. +func TestValidateCreate_RejectsGlobalKeystoneOptionUnknownToGlance(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := glanceControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"token": {"expiration": "3600"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[token][expiration]")) + g.Expect(err.Error()).To(ContainSubstring("no such section in the glance 2025.2 option catalog")) +} + +// TestValidateCreate_RejectsUnknownGlanceExtraConfigOption pins a per-service +// Glance override the catalog does not accept. +func TestValidateCreate_RejectsUnknownGlanceExtraConfigOption(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := glanceControlPlane() + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{"DEFAULT": {"workerz": "4"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.glance.extraConfig[DEFAULT][workerz]")) + g.Expect(err.Error()).To(ContainSubstring("no such option in the glance 2025.2 option catalog")) +} + +// TestValidateCreate_RejectsUnknownOptionInBothBlocks pins the by-membership +// attribution: an unknown key present in both the global and the per-service +// block yields exactly two errors, one per contributing path. +func TestValidateCreate_RejectsUnknownOptionInBothBlocks(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"token": {"providr": "x"}} + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{"token": {"providr": "y"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[token][providr]")) + g.Expect(err.Error()).To(ContainSubstring("spec.services.keystone.extraConfig[token][providr]")) + g.Expect(strings.Count(err.Error(), "no such option in the keystone 2025.2 option catalog")). + To(Equal(2), "one error per contributing block") +} + +// TestValidateCreate_RejectsUnknownSection pins the unknown-section message: it +// names the ControlPlane framing and never leaks the child webhook's +// "declare via spec.plugins" hint. +func TestValidateCreate_RejectsUnknownSection(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"notasection": {"foo": "bar"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[notasection][foo]")) + g.Expect(err.Error()).To(ContainSubstring("plugin-registered sections are not configurable through the ControlPlane")) + g.Expect(err.Error()).NotTo(ContainSubstring("spec.plugins")) +} + +// TestValidateCreate_ForbidsGlanceRejectedOwnedKey pins that [keystone_authtoken] +// password is Forbidden regardless of which block carries it — rendering it would +// leak the service password into the namespace-readable ConfigMap. +func TestValidateCreate_ForbidsGlanceRejectedOwnedKey(t *testing.T) { + w := &ControlPlaneWebhook{} + + t.Run("in globalExtraConfig", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := glanceControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"keystone_authtoken": {"password": "s3cr3t"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[keystone_authtoken][password]")) + g.Expect(err.Error()).To(ContainSubstring("password is managed via spec.serviceUser.secretRef and must not be set in extraConfig")) + }) + + t.Run("in glance extraConfig", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := glanceControlPlane() + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{"keystone_authtoken": {"password": "s3cr3t"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.glance.extraConfig[keystone_authtoken][password]")) + g.Expect(err.Error()).To(ContainSubstring("must not be set in extraConfig")) + }) +} + +// TestValidateCreate_TrustedDashboardOwnership pins the conditional gate on the +// merged Keystone [federation] trusted_dashboard: Forbidden when the ControlPlane +// derives a dashboard endpoint from services.horizon (publicEndpoint or a bare +// gateway hostname), but admitted with an owned-key warning when no dashboard is +// derived (an externally-run dashboard doing WebSSO is legitimate). +func TestValidateCreate_TrustedDashboardOwnership(t *testing.T) { + w := &ControlPlaneWebhook{} + withTrustedDashboard := func() *ControlPlane { + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{ + "federation": {"trusted_dashboard": "https://dash.example.com/auth/websso/"}, + } + return cp + } + + t.Run("rejected with horizon publicEndpoint", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := withTrustedDashboard() + cp.Spec.Services.Horizon = &ServiceHorizonSpec{PublicEndpoint: "https://dash.example.com"} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[federation][trusted_dashboard]")) + g.Expect(err.Error()).To(ContainSubstring("Horizon-derived trusted-dashboards projection")) + }) + + t.Run("rejected with horizon gateway hostname only", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := withTrustedDashboard() + cp.Spec.Services.Horizon = &ServiceHorizonSpec{Gateway: &commonv1.GatewaySpec{Hostname: "dash.example.com"}} + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[federation][trusted_dashboard]")) + }) + + t.Run("admitted with warning when horizon absent", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := withTrustedDashboard() + + warnings, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(ContainElement(ContainSubstring("[federation] trusted_dashboard"))) + }) +} + +// TestValidateCreate_ForbidsHorizonRejectedSettings pins that SECRET_KEY and one +// WebSSO and one multi-domain setting are Forbidden unconditionally at their real +// leaves — the ControlPlane projects websso/multiDomain dynamically, so a collision +// is not decidable at admission and the key would plant a runtime wedge. +func TestValidateCreate_ForbidsHorizonRejectedSettings(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.Services.Horizon = &ServiceHorizonSpec{ + ExtraConfig: map[string]apiextensionsv1.JSON{ + "SECRET_KEY": jsonSetting(`"x"`), + "WEBSSO_ENABLED": jsonSetting("true"), + "OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT": jsonSetting("true"), + }, + } + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.horizon.extraConfig[SECRET_KEY]")) + g.Expect(err.Error()).To(ContainSubstring("spec.services.horizon.extraConfig[WEBSSO_ENABLED]")) + g.Expect(err.Error()).To(ContainSubstring("spec.services.horizon.extraConfig[OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT]")) +} + +// TestValidateCreate_RejectsMalformedExtraConfigShape pins the shape checks on +// every block: empty section, empty key, empty Horizon setting name, and a +// Horizon setting that is not a Python identifier. +func TestValidateCreate_RejectsMalformedExtraConfigShape(t *testing.T) { + w := &ControlPlaneWebhook{} + + t.Run("empty global section name", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"": {"k": "v"}} + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig")) + g.Expect(err.Error()).To(ContainSubstring("extraConfig section name must not be empty")) + }) + + t.Run("empty keystone option key", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := validControlPlane() + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{"token": {"": "v"}} + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.keystone.extraConfig[token]")) + g.Expect(err.Error()).To(ContainSubstring("extraConfig key must not be empty")) + }) + + t.Run("empty glance section name", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := glanceControlPlane() + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{"": {"k": "v"}} + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.glance.extraConfig")) + g.Expect(err.Error()).To(ContainSubstring("extraConfig section name must not be empty")) + }) + + t.Run("empty horizon setting name", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := validControlPlane() + cp.Spec.Services.Horizon = &ServiceHorizonSpec{ + ExtraConfig: map[string]apiextensionsv1.JSON{"": jsonSetting(`"x"`)}, + } + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.horizon.extraConfig")) + g.Expect(err.Error()).To(ContainSubstring("extraConfig setting name must not be empty")) + }) + + t.Run("non-identifier horizon setting name", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := validControlPlane() + cp.Spec.Services.Horizon = &ServiceHorizonSpec{ + ExtraConfig: map[string]apiextensionsv1.JSON{"not-an-identifier": jsonSetting(`"x"`)}, + } + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.horizon.extraConfig[not-an-identifier]")) + g.Expect(err.Error()).To(ContainSubstring("must be a valid Python identifier")) + }) +} + +// TestValidateCreate_RejectsExtraConfigINIInjection pins the value-side INI +// injection guard: the ownership and catalog gates match by (section, key) name +// only, so a newline smuggled into a value (or a section name / key) renders a +// whole extra [section]/key the gates never inspected. The classic PoC smuggles +// the operator-owned [federation] trusted_dashboard through a legitimate +// [DEFAULT] debug value; without the shape guard it slips past every gate and +// Keystone POSTs WebSSO tokens to the attacker origin. +func TestValidateCreate_RejectsExtraConfigINIInjection(t *testing.T) { + w := &ControlPlaneWebhook{} + + t.Run("newline in global value smuggles a section", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{ + "DEFAULT": {"debug": "false\n[federation]\ntrusted_dashboard = https://attacker.example"}, + } + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[DEFAULT][debug]")) + g.Expect(err.Error()).To(ContainSubstring("must not contain a newline or carriage return")) + }) + + t.Run("carriage return in keystone value", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := validControlPlane() + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{ + "DEFAULT": {"debug": "false\r[database]\rconnection = mysql://attacker/x"}, + } + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.keystone.extraConfig[DEFAULT][debug]")) + g.Expect(err.Error()).To(ContainSubstring("must not contain a newline or carriage return")) + }) + + t.Run("newline in glance key", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := glanceControlPlane() + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{ + "DEFAULT": {"debug\n[keystone_authtoken]\npassword": "s3cr3t"}, + } + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.glance.extraConfig[DEFAULT]")) + g.Expect(err.Error()).To(ContainSubstring("must not contain a newline or carriage return")) + }) + + t.Run("newline in global section name", func(t *testing.T) { + g := NewGomegaWithT(t) + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{ + "DEFAULT\n[federation]": {"k": "v"}, + } + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig")) + g.Expect(err.Error()).To(ContainSubstring("section name must not contain a newline or carriage return")) + }) +} + +// TestValidateCreate_ForbidsKeystoneExtraConfigInExternalMode pins the webhook +// mirror of the CEL rule: services.keystone.extraConfig is Forbidden in External +// mode (no Keystone workload is deployed). +func TestValidateCreate_ForbidsKeystoneExtraConfigInExternalMode(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.Infrastructure = nil + cp.Spec.Services.Keystone = &ServiceKeystoneSpec{ + Mode: KeystoneModeExternal, + External: &ExternalKeystoneSpec{AuthURL: "https://keystone.example.com/v3"}, + ExtraConfig: map[string]map[string]string{"token": {"expiration": "3600"}}, + } + + _, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.services.keystone.extraConfig")) + g.Expect(err.Error()).To(ContainSubstring("forbidden when services.keystone.mode is External")) +} + +// TestValidateCreate_AcceptsValidOptionInBothBlocks pins that a catalog-valid, +// non-owned option present in both the global and the per-service block is +// admitted (no double-count, no spurious rejection). +func TestValidateCreate_AcceptsValidOptionInBothBlocks(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"token": {"expiration": "3600"}} + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{"token": {"expiration": "7200"}} + + warnings, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(BeEmpty()) +} + +// TestValidateCreate_AcceptsAllFourExtraConfigBlocks is the acceptance base case: +// a ControlPlane with all four blocks populated with catalog-valid, non-owned +// content is admitted with no warnings. +func TestValidateCreate_AcceptsAllFourExtraConfigBlocks(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := glanceControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"DEFAULT": {"log_date_format": "%Y-%m-%d %H:%M:%S"}} + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{"token": {"expiration": "3600"}} + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{"cors": {"allowed_origin": "https://example.com"}} + cp.Spec.Services.Horizon = &ServiceHorizonSpec{ + ExtraConfig: map[string]apiextensionsv1.JSON{"CUSTOM_SETTING": jsonSetting("true")}, + } + + warnings, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(BeEmpty()) +} + +// TestValidateCreate_AcceptsAbsentExtraConfigBlocks pins that a CR with no +// extraConfig at all raises no extraConfig warning and no extraConfig error. +func TestValidateCreate_AcceptsAbsentExtraConfigBlocks(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + + warnings, err := w.ValidateCreate(context.Background(), validControlPlane()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(BeEmpty()) +} + +// TestValidateCreate_WarnsOnReportedOwnedKey pins that a Reported owned key is +// honored but surfaced: the warning names the key, its OwnedBy source, and the +// contributing block path, and the CR is admitted. +func TestValidateCreate_WarnsOnReportedOwnedKey(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"DEFAULT": {"debug": "true"}} + + warnings, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(ContainElement(And( + ContainSubstring("[DEFAULT] debug"), + ContainSubstring("operator-computed"), + ContainSubstring("spec.globalExtraConfig[DEFAULT][debug]"), + ))) +} + +// TestValidateCreate_WarnsOnDeprecatedOption pins that a deprecated-but-accepted +// option is admitted with a warning naming its replacement. +func TestValidateCreate_WarnsOnDeprecatedOption(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"DEFAULT": {"logfile": "/var/log/keystone.log"}} + + warnings, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(ContainElement(And( + ContainSubstring("logfile"), + ContainSubstring("replaced by [DEFAULT] log_file"), + ))) +} + +// TestValidateCreate_FailsOpenOnDigestPinnedKeystoneImage pins the fail-open: a +// digest-pinned Keystone image names no release, so the merged config is not +// validated — exactly one warning, zero errors. +func TestValidateCreate_FailsOpenOnDigestPinnedKeystoneImage(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := validControlPlane() + cp.Spec.Services.Keystone.Image = &commonv1.ImageSpec{ + Repository: "ghcr.io/c5c3/keystone", + Digest: "sha256:" + strings.Repeat("a", 64), + } + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{"token": {"expiration": "3600"}} + + warnings, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(HaveLen(1)) + g.Expect(warnings[0]).To(ContainSubstring("does not name an OpenStack release")) +} + +// TestValidateCreate_FailsOpenOnCatalogLessRelease pins the fail-open for a +// syntactically valid release the build ships no catalog for: exactly one warning +// per declared INI service, zero errors. +func TestValidateCreate_FailsOpenOnCatalogLessRelease(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + cp := glanceControlPlane() + cp.Spec.OpenStackRelease = "2027.1" + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{"token": {"expiration": "3600"}} + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{"cors": {"allowed_origin": "https://example.com"}} + + warnings, err := w.ValidateCreate(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(HaveLen(2)) + g.Expect(warnings).To(ContainElement(And( + ContainSubstring("keystone"), + ContainSubstring(`no catalog for release "2027.1"`), + ))) + g.Expect(warnings).To(ContainElement(And( + ContainSubstring("glance"), + ContainSubstring(`no catalog for release "2027.1"`), + ))) +} + +// TestValidateUpdate_ExtraConfigCatalogGating pins the Family B update gate: a +// stored CR whose extraConfig is no longer catalog-valid stays mutable by an +// unrelated edit, but any edit that changes a catalog input re-validates and +// rejects. The stored (old) object is built directly, bypassing admission. +func TestValidateUpdate_ExtraConfigCatalogGating(t *testing.T) { + w := &ControlPlaneWebhook{} + staleInvalid := func() *ControlPlane { + cp := validControlPlane() + cp.Name = "cp" + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"token": {"providr": "fernet"}} + return cp + } + + t.Run("unrelated replicas bump is admitted", func(t *testing.T) { + g := NewGomegaWithT(t) + oldCP := staleInvalid() + newCP := staleInvalid() + replicas := int32(2) + newCP.Spec.Services.Keystone.Replicas = &replicas + + _, err := w.ValidateUpdate(context.Background(), oldCP, newCP) + g.Expect(err).NotTo(HaveOccurred()) + }) + + t.Run("editing the block re-validates and rejects", func(t *testing.T) { + g := NewGomegaWithT(t) + oldCP := staleInvalid() + newCP := staleInvalid() + newCP.Spec.GlobalExtraConfig = map[string]map[string]string{"token": {"providr2": "fernet"}} + + _, err := w.ValidateUpdate(context.Background(), oldCP, newCP) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2025.2 option catalog")) + }) + + t.Run("changing openStackRelease re-validates and rejects", func(t *testing.T) { + g := NewGomegaWithT(t) + oldCP := staleInvalid() + newCP := staleInvalid() + newCP.Spec.OpenStackRelease = "2026.1" + + _, err := w.ValidateUpdate(context.Background(), oldCP, newCP) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2026.1 option catalog")) + }) + + t.Run("changing keystone image re-validates and rejects", func(t *testing.T) { + g := NewGomegaWithT(t) + oldCP := staleInvalid() + newCP := staleInvalid() + newCP.Spec.Services.Keystone.Image = &commonv1.ImageSpec{Repository: "ghcr.io/c5c3/keystone", Tag: "2025.2"} + + _, err := w.ValidateUpdate(context.Background(), oldCP, newCP) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2025.2 option catalog")) + }) + + t.Run("newly declaring glance re-validates and rejects", func(t *testing.T) { + g := NewGomegaWithT(t) + oldCP := staleInvalid() + newCP := staleInvalid() + newCP.Spec.Services.Glance = validGlanceSpec() + newCP.Spec.KORC.ServiceAccounts = []ServiceAccountSpec{{ + Name: "glance", + Project: ServiceAccountProjectSpec{Name: "service", Create: true}, + Roles: []string{"service"}, + }} + + _, err := w.ValidateUpdate(context.Background(), oldCP, newCP) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[token][providr]")) + }) +} + +// TestValidateUpdate_TrustedDashboardRejectedWhenHorizonAppears pins that Family A +// is un-gated on update: newly declaring a Horizon dashboard endpoint turns a +// stored, previously-admitted [federation] trusted_dashboard override into a +// rejection. +func TestValidateUpdate_TrustedDashboardRejectedWhenHorizonAppears(t *testing.T) { + g := NewGomegaWithT(t) + w := &ControlPlaneWebhook{} + oldCP := validControlPlane() + oldCP.Name = "cp" + oldCP.Spec.GlobalExtraConfig = map[string]map[string]string{ + "federation": {"trusted_dashboard": "https://dash.example.com/auth/websso/"}, + } + newCP := oldCP.DeepCopy() + newCP.Spec.Services.Horizon = &ServiceHorizonSpec{PublicEndpoint: "https://dash.example.com"} + + _, err := w.ValidateUpdate(context.Background(), oldCP, newCP) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.globalExtraConfig[federation][trusted_dashboard]")) + g.Expect(err.Error()).To(ContainSubstring("Horizon-derived trusted-dashboards projection")) +} diff --git a/operators/c5c3/api/v1alpha1/zz_generated.deepcopy.go b/operators/c5c3/api/v1alpha1/zz_generated.deepcopy.go index ba7490070..1d90d5bc9 100644 --- a/operators/c5c3/api/v1alpha1/zz_generated.deepcopy.go +++ b/operators/c5c3/api/v1alpha1/zz_generated.deepcopy.go @@ -10,6 +10,7 @@ package v1alpha1 import ( "github.com/c5c3/forge/internal/common/types" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -240,6 +241,24 @@ func (in *ControlPlaneSpec) DeepCopyInto(out *ControlPlaneSpec) { *out = new(types.PolicySpec) (*in).DeepCopyInto(*out) } + if in.GlobalExtraConfig != nil { + in, out := &in.GlobalExtraConfig, &out.GlobalExtraConfig + *out = make(map[string]map[string]string, len(*in)) + for key, val := range *in { + var outVal map[string]string + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + (*out)[key] = outVal + } + } in.KORC.DeepCopyInto(&out.KORC) } @@ -862,6 +881,24 @@ func (in *ServiceGlanceSpec) DeepCopyInto(out *ServiceGlanceSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ExtraConfig != nil { + in, out := &in.ExtraConfig, &out.ExtraConfig + *out = make(map[string]map[string]string, len(*in)) + for key, val := range *in { + var outVal map[string]string + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + (*out)[key] = outVal + } + } if in.DedicatedBackingServices != nil { in, out := &in.DedicatedBackingServices, &out.DedicatedBackingServices *out = new(GlanceDedicatedBackingServicesSpec) @@ -907,6 +944,13 @@ func (in *ServiceHorizonSpec) DeepCopyInto(out *ServiceHorizonSpec) { *out = new(types.SecretRefSpec) **out = **in } + if in.ExtraConfig != nil { + in, out := &in.ExtraConfig, &out.ExtraConfig + *out = make(map[string]apiextensionsv1.JSON, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } if in.DedicatedBackingServices != nil { in, out := &in.DedicatedBackingServices, &out.DedicatedBackingServices *out = new(HorizonDedicatedBackingServicesSpec) @@ -952,6 +996,24 @@ func (in *ServiceKeystoneSpec) DeepCopyInto(out *ServiceKeystoneSpec) { *out = new(types.PolicySpec) (*in).DeepCopyInto(*out) } + if in.ExtraConfig != nil { + in, out := &in.ExtraConfig, &out.ExtraConfig + *out = make(map[string]map[string]string, len(*in)) + for key, val := range *in { + var outVal map[string]string + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + (*out)[key] = outVal + } + } if in.RotationInterval != nil { in, out := &in.RotationInterval, &out.RotationInterval *out = new(v1.Duration) diff --git a/operators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml b/operators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml index faed7c93c..25d624a17 100644 --- a/operators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml +++ b/operators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml @@ -54,6 +54,22 @@ spec: spec: description: ControlPlaneSpec defines the desired state of a ControlPlane. properties: + globalExtraConfig: + additionalProperties: + additionalProperties: + type: string + type: object + description: |- + GlobalExtraConfig is a free-form INI block applied to every INI-configured + service the ControlPlane declares (Keystone and Glance today). It is merged + key by key with each service's own extraConfig — sections are unioned, the + per-service value wins per key, and a global key with no per-service + counterpart stays effective — before the merged result is projected onto + the child CR. It NEVER applies to Horizon: the dashboard renders flat Django + settings, not INI, so services.horizon.extraConfig stands alone. Legal but + inert in External mode (no INI-configured workload is deployed), the same + posture globalPolicyOverrides has. + type: object globalPolicyOverrides: description: |- GlobalPolicyOverrides defines oslo.policy overrides applied across every @@ -1170,6 +1186,17 @@ spec: - message: dedicatedBackingServices must declare at least one backing-service class (database, cache) rule: has(self.database) || has(self.cache) + extraConfig: + additionalProperties: + additionalProperties: + type: string + type: object + description: |- + ExtraConfig is a free-form INI block for the Glance service. It is merged + key by key with spec.globalExtraConfig — sections unioned, this per-service + value winning per key — and the merged result is projected onto the Glance + child's spec.extraConfig. + type: object gateway: description: |- Gateway optionally exposes the projected Glance API externally via a @@ -1400,6 +1427,17 @@ spec: - message: dedicatedBackingServices must declare at least one backing-service class (cache) rule: has(self.cache) + extraConfig: + additionalProperties: + x-kubernetes-preserve-unknown-fields: true + description: |- + ExtraConfig carries free-form flat Django settings, mirroring the Horizon + child's spec.extraConfig (operators/horizon/api/v1alpha1/horizon_types.go). + Keys are Django setting names and values are arbitrary JSON; the reconciler + projects the block verbatim onto the Horizon child. It is NOT an INI block, + so spec.globalExtraConfig — which is INI, applied only to the INI-configured + services — never applies to it. + type: object gateway: description: |- Gateway optionally exposes the projected dashboard externally via a @@ -2087,6 +2125,18 @@ spec: required: - authURL type: object + extraConfig: + additionalProperties: + additionalProperties: + type: string + type: object + description: |- + ExtraConfig is a free-form INI block for the Keystone service. It is merged + key by key with spec.globalExtraConfig — sections unioned, this per-service + value winning per key — and the merged result is projected onto the Keystone + child's spec.extraConfig. Forbidden in External mode (CEL + webhook + enforced): no Keystone workload is deployed, so there is no config to render. + type: object federationProxyImage: description: |- FederationProxyImage optionally overrides the mod_auth_openidc sidecar @@ -2392,6 +2442,9 @@ spec: - message: services.keystone.databaseCredentialsMode is forbidden when services.keystone.mode is External rule: '!(has(self.mode) && self.mode == ''External'') || !has(self.databaseCredentialsMode)' + - message: services.keystone.extraConfig is forbidden when services.keystone.mode + is External + rule: '!(has(self.mode) && self.mode == ''External'') || !has(self.extraConfig)' type: object required: - korc diff --git a/operators/c5c3/go.mod b/operators/c5c3/go.mod index 907072382..0079bbd29 100644 --- a/operators/c5c3/go.mod +++ b/operators/c5c3/go.mod @@ -14,6 +14,7 @@ require ( github.com/prometheus/client_golang v1.24.0 github.com/prometheus/client_model v0.6.2 k8s.io/api v0.36.2 + k8s.io/apiextensions-apiserver v0.36.2 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 @@ -31,7 +32,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect @@ -71,13 +72,12 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.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 - k8s.io/apiextensions-apiserver v0.36.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260501160325-927ab1f70cd6 // indirect sigs.k8s.io/gateway-api v1.6.1 // indirect diff --git a/operators/c5c3/go.sum b/operators/c5c3/go.sum index 4e136acfc..560a0fce4 100644 --- a/operators/c5c3/go.sum +++ b/operators/c5c3/go.sum @@ -24,8 +24,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= @@ -79,8 +79,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/k-orc/openstack-resource-controller/v2 v2.5.1-0.20260708153253-ef6119d9effa h1:lbaZcNL7NjewHGKsM2O9OfO0sPBtCcilt2mEY0Kk9SQ= github.com/k-orc/openstack-resource-controller/v2 v2.5.1-0.20260708153253-ef6119d9effa/go.mod h1:1Cj8Vz1OGH9fitp+BD/9MXEloS/DKqRR5KVFI7/ea1s= -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/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -108,18 +108,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -148,26 +142,24 @@ 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.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/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/operators/c5c3/helm/c5c3-operator/crds/c5c3.io_controlplanes.yaml b/operators/c5c3/helm/c5c3-operator/crds/c5c3.io_controlplanes.yaml index 42cb9be2d..e0fc9aa10 100644 --- a/operators/c5c3/helm/c5c3-operator/crds/c5c3.io_controlplanes.yaml +++ b/operators/c5c3/helm/c5c3-operator/crds/c5c3.io_controlplanes.yaml @@ -57,6 +57,22 @@ spec: spec: description: ControlPlaneSpec defines the desired state of a ControlPlane. properties: + globalExtraConfig: + additionalProperties: + additionalProperties: + type: string + type: object + description: |- + GlobalExtraConfig is a free-form INI block applied to every INI-configured + service the ControlPlane declares (Keystone and Glance today). It is merged + key by key with each service's own extraConfig — sections are unioned, the + per-service value wins per key, and a global key with no per-service + counterpart stays effective — before the merged result is projected onto + the child CR. It NEVER applies to Horizon: the dashboard renders flat Django + settings, not INI, so services.horizon.extraConfig stands alone. Legal but + inert in External mode (no INI-configured workload is deployed), the same + posture globalPolicyOverrides has. + type: object globalPolicyOverrides: description: |- GlobalPolicyOverrides defines oslo.policy overrides applied across every @@ -1173,6 +1189,17 @@ spec: - message: dedicatedBackingServices must declare at least one backing-service class (database, cache) rule: has(self.database) || has(self.cache) + extraConfig: + additionalProperties: + additionalProperties: + type: string + type: object + description: |- + ExtraConfig is a free-form INI block for the Glance service. It is merged + key by key with spec.globalExtraConfig — sections unioned, this per-service + value winning per key — and the merged result is projected onto the Glance + child's spec.extraConfig. + type: object gateway: description: |- Gateway optionally exposes the projected Glance API externally via a @@ -1403,6 +1430,17 @@ spec: - message: dedicatedBackingServices must declare at least one backing-service class (cache) rule: has(self.cache) + extraConfig: + additionalProperties: + x-kubernetes-preserve-unknown-fields: true + description: |- + ExtraConfig carries free-form flat Django settings, mirroring the Horizon + child's spec.extraConfig (operators/horizon/api/v1alpha1/horizon_types.go). + Keys are Django setting names and values are arbitrary JSON; the reconciler + projects the block verbatim onto the Horizon child. It is NOT an INI block, + so spec.globalExtraConfig — which is INI, applied only to the INI-configured + services — never applies to it. + type: object gateway: description: |- Gateway optionally exposes the projected dashboard externally via a @@ -2090,6 +2128,18 @@ spec: required: - authURL type: object + extraConfig: + additionalProperties: + additionalProperties: + type: string + type: object + description: |- + ExtraConfig is a free-form INI block for the Keystone service. It is merged + key by key with spec.globalExtraConfig — sections unioned, this per-service + value winning per key — and the merged result is projected onto the Keystone + child's spec.extraConfig. Forbidden in External mode (CEL + webhook + enforced): no Keystone workload is deployed, so there is no config to render. + type: object federationProxyImage: description: |- FederationProxyImage optionally overrides the mod_auth_openidc sidecar @@ -2395,6 +2445,9 @@ spec: - message: services.keystone.databaseCredentialsMode is forbidden when services.keystone.mode is External rule: '!(has(self.mode) && self.mode == ''External'') || !has(self.databaseCredentialsMode)' + - message: services.keystone.extraConfig is forbidden when services.keystone.mode + is External + rule: '!(has(self.mode) && self.mode == ''External'') || !has(self.extraConfig)' type: object required: - korc diff --git a/operators/c5c3/internal/controller/identity_backends.go b/operators/c5c3/internal/controller/identity_backends.go index ef228a3f9..92b513bf8 100644 --- a/operators/c5c3/internal/controller/identity_backends.go +++ b/operators/c5c3/internal/controller/identity_backends.go @@ -10,7 +10,6 @@ import ( "encoding/hex" "fmt" "sort" - "strings" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -150,28 +149,6 @@ func webSSOChoiceID(b *keystonev1alpha1.KeystoneIdentityBackend) string { return id[:maxWebSSOChoiceIDLen-len(suffix)] + suffix } -// horizonPublicEndpoint returns the BROWSER-observed dashboard base URL, with -// any trailing slash trimmed so the derived WebSSO origin carries exactly one. -// An explicit publicEndpoint wins; otherwise, when a gateway is set, it is -// derived as "https://{gateway.hostname}" (the default-443 form). When neither -// is set it returns "" — no dashboard is externally reachable, so there is no -// origin to trust. -// -// It mirrors keystonePublicEndpoint and takes the *ServiceHorizonSpec for the -// same reason: a nil pointer (services.horizon unset) yields "". -func horizonPublicEndpoint(hz *c5c3v1alpha1.ServiceHorizonSpec) string { - if hz == nil { - return "" - } - if hz.PublicEndpoint != "" { - return strings.TrimRight(hz.PublicEndpoint, "/") - } - if hz.Gateway != nil && hz.Gateway.Hostname != "" { - return "https://" + hz.Gateway.Hostname - } - return "" -} - // identityBackendToControlPlaneMapper maps a KeystoneIdentityBackend event onto // the ControlPlane whose Keystone child the backend attaches to. // diff --git a/operators/c5c3/internal/controller/identity_backends_test.go b/operators/c5c3/internal/controller/identity_backends_test.go index d55f796f9..34c33c243 100644 --- a/operators/c5c3/internal/controller/identity_backends_test.go +++ b/operators/c5c3/internal/controller/identity_backends_test.go @@ -218,7 +218,7 @@ func TestHorizonPublicEndpoint(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { g := NewGomegaWithT(t) - g.Expect(horizonPublicEndpoint(tc.hz)).To(Equal(tc.want)) + g.Expect(tc.hz.DerivedPublicEndpoint()).To(Equal(tc.want)) }) } } diff --git a/operators/c5c3/internal/controller/reconcile_glance.go b/operators/c5c3/internal/controller/reconcile_glance.go index 72f4d907a..2775cc81e 100644 --- a/operators/c5c3/internal/controller/reconcile_glance.go +++ b/operators/c5c3/internal/controller/reconcile_glance.go @@ -403,6 +403,14 @@ func (r *ControlPlaneReconciler) reconcileGlance(ctx context.Context, cp *c5c3v1 glance.Spec.OpenStackRelease = cp.Spec.OpenStackRelease glance.Spec.Image = image + // Project the merged extraConfig (globalExtraConfig unioned with the + // per-service block, per-service winning key by key). Assigned + // unconditionally, following the revert-on-clear convention: a nil merge keeps + // the SSA-applied intent free of spec.extraConfig, so a direct edit on the + // child stays unowned until a ControlPlane block is set — and clearing the + // ControlPlane block reverts the child rather than pinning the last value. + glance.Spec.ExtraConfig = c5c3v1alpha1.MergedExtraConfig(cp.Spec.GlobalExtraConfig, cp.Spec.Services.Glance.ExtraConfig) + // Point Glance at the SAME backing services the ControlPlane provisioned. The // logical database is always "glance" — its own schema keeps it isolated from // Keystone's on a shared cluster. DeepCopy (over a plain struct copy) is diff --git a/operators/c5c3/internal/controller/reconcile_glance_test.go b/operators/c5c3/internal/controller/reconcile_glance_test.go index 10f1bc856..4525baf20 100644 --- a/operators/c5c3/internal/controller/reconcile_glance_test.go +++ b/operators/c5c3/internal/controller/reconcile_glance_test.go @@ -624,6 +624,65 @@ func TestReconcileGlance_ImageOverrideWins(t *testing.T) { g.Expect(gl.Spec.Image.Tag).To(Equal("custom")) } +// TestReconcileGlance_ExtraConfigMerge proves the projected child's +// spec.extraConfig is the key-by-key merge of globalExtraConfig and the +// per-service block: the per-service value wins on an overlapping key, a +// global-only key in the same section survives, and a global-only section is +// carried over. +func TestReconcileGlance_ExtraConfigMerge(t *testing.T) { + g := NewGomegaWithT(t) + cp := glanceControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{ + "database": { + "connection_recycle_time": "280", + "max_pool_size": "5", + }, + "DEFAULT": {"debug": "true"}, + } + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{ + "database": {"connection_recycle_time": "600"}, // overrides global + } + r := newGlanceTestReconciler(t, cp) + + _, err := r.reconcileGlance(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + + gl := getProjectedGlance(t, r.Client, cp) + g.Expect(gl.Spec.ExtraConfig).To(Equal(map[string]map[string]string{ + "database": { + "connection_recycle_time": "600", // per-service wins + "max_pool_size": "5", // global-only key in the same section + }, + "DEFAULT": {"debug": "true"}, // global-only section + }), "per-service extraConfig must win, global keys/sections merged in") +} + +// TestReconcileGlance_ExtraConfigClearedProjectsNil proves the field is assigned +// unconditionally: clearing both extraConfig blocks reverts the child to an +// absent spec.extraConfig rather than leaving the previously-projected value +// pinned. +func TestReconcileGlance_ExtraConfigClearedProjectsNil(t *testing.T) { + g := NewGomegaWithT(t) + cp := glanceControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"DEFAULT": {"debug": "true"}} + cp.Spec.Services.Glance.ExtraConfig = map[string]map[string]string{ + "database": {"connection_recycle_time": "600"}, + } + r := newGlanceTestReconciler(t, cp) + ctx := context.Background() + + _, err := r.reconcileGlance(ctx, cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(getProjectedGlance(t, r.Client, cp).Spec.ExtraConfig).NotTo(BeEmpty()) + + cp.Spec.GlobalExtraConfig = nil + cp.Spec.Services.Glance.ExtraConfig = nil + _, err = r.reconcileGlance(ctx, cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(getProjectedGlance(t, r.Client, cp).Spec.ExtraConfig).To(BeNil(), + "clearing both extraConfig blocks must revert the child") +} + // TestReconcileGlance_DatabaseManagedProjection verifies the managed-mode DB // wiring: the logical schema is always "glance", the secretRef points at the // operator-owned DB-credential Secret, and credentialsMode is Dynamic by default diff --git a/operators/c5c3/internal/controller/reconcile_horizon.go b/operators/c5c3/internal/controller/reconcile_horizon.go index f0071926b..e8c2e0729 100644 --- a/operators/c5c3/internal/controller/reconcile_horizon.go +++ b/operators/c5c3/internal/controller/reconcile_horizon.go @@ -9,6 +9,7 @@ import ( "fmt" "strconv" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -253,6 +254,14 @@ func (r *ControlPlaneReconciler) reconcileHorizon(ctx context.Context, cp *c5c3v horizon.Spec.Deployment.Replicas = *cp.Spec.Services.Horizon.Replicas } + // Project the dashboard's extraConfig verbatim. Horizon is flat Django + // settings, not INI, so the global INI block never applies — only the + // per-service block is projected, deep-copied so the child never aliases + // cp.Spec. Assigned unconditionally, following the revert-on-clear + // convention: a nil/empty source clears the child's block rather than + // pinning the last projected value. + horizon.Spec.ExtraConfig = deepCopyHorizonExtraConfig(cp.Spec.Services.Horizon.ExtraConfig) + // Project the federated-login surface from the Ready backends. // Detaching the last backend clears the block so the login page reverts // to local credentials rather than keeping a dead SSO button pinned on @@ -400,7 +409,7 @@ func horizonWebSSO(ctx context.Context, cp *c5c3v1alpha1.ControlPlane, backends logger.Info("Ready OIDC identity backends found, but the WebSSO hand-off has no browser-facing endpoints; "+ "omitting the login page's SSO choices", "readyBackends", len(federated), - "trustedDashboardOrigin", horizonPublicEndpoint(cp.Spec.Services.Horizon), + "trustedDashboardOrigin", cp.Spec.Services.Horizon.DerivedPublicEndpoint(), "keystoneURL", keystoneURL) return nil } @@ -479,6 +488,22 @@ func horizonKeystoneEndpoint(cp *c5c3v1alpha1.ControlPlane) string { return keystoneEndpointURL(cp) } +// deepCopyHorizonExtraConfig returns an independent copy of the dashboard's +// flat Django extraConfig block so the projected child never aliases cp.Spec: a +// shared map (or a shared JSON value's Raw bytes) would let a later mutation of +// the child leak back into the ControlPlane spec. A nil or empty source yields +// nil, so an absent block projects an absent field rather than an empty map. +func deepCopyHorizonExtraConfig(src map[string]apiextensionsv1.JSON) map[string]apiextensionsv1.JSON { + if len(src) == 0 { + return nil + } + out := make(map[string]apiextensionsv1.JSON, len(src)) + for k, v := range src { + out[k] = *v.DeepCopy() + } + return out +} + // deleteOrphanedHorizon removes a previously-projected Horizon child when // spec.services.horizon is unset AND the ControlPlane has opted in to // deletion via horizonDeletionAllowedAnnotation (the caller gates this). It is diff --git a/operators/c5c3/internal/controller/reconcile_horizon_test.go b/operators/c5c3/internal/controller/reconcile_horizon_test.go index f1bbc16c9..74ad36cc9 100644 --- a/operators/c5c3/internal/controller/reconcile_horizon_test.go +++ b/operators/c5c3/internal/controller/reconcile_horizon_test.go @@ -12,6 +12,7 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -460,6 +461,61 @@ func TestReconcileHorizon_ReplicasRevertsToDefaultWhenCleared(t *testing.T) { g.Expect(h.Spec.Deployment.Replicas).To(Equal(commonv1.DefaultReplicas)) } +// TestReconcileHorizon_ExtraConfigProjectedVerbatim proves the dashboard's flat +// Django extraConfig block is projected onto the child verbatim, and that the +// child never aliases cp.Spec: mutating the projected map (a new key, and a +// value's Raw bytes) leaves the ControlPlane spec untouched. +func TestReconcileHorizon_ExtraConfigProjectedVerbatim(t *testing.T) { + g := NewGomegaWithT(t) + cp := horizonControlPlane() + cp.Spec.Services.Horizon.ExtraConfig = map[string]apiextensionsv1.JSON{ + "SESSION_TIMEOUT": {Raw: []byte("3600")}, + } + r := newHorizonTestReconciler(t, cp) + + _, err := r.reconcileHorizon(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + + h := getProjectedHorizon(t, r.Client, cp) + g.Expect(h.Spec.ExtraConfig).To(HaveKeyWithValue( + "SESSION_TIMEOUT", apiextensionsv1.JSON{Raw: []byte("3600")}, + ), + "the Django setting must be projected verbatim") + + // No aliasing: mutating the projected child's map and a value's Raw bytes must + // not reach back into the ControlPlane spec. + entry := h.Spec.ExtraConfig["SESSION_TIMEOUT"] + entry.Raw[0] = '9' + h.Spec.ExtraConfig["INJECTED"] = apiextensionsv1.JSON{Raw: []byte("true")} + g.Expect(cp.Spec.Services.Horizon.ExtraConfig).To(Equal(map[string]apiextensionsv1.JSON{ + "SESSION_TIMEOUT": {Raw: []byte("3600")}, + }), "the child must not alias cp.Spec.Services.Horizon.ExtraConfig") +} + +// TestReconcileHorizon_ExtraConfigClearedProjectsNil proves the field is +// assigned unconditionally: clearing the extraConfig block reverts the child to +// an absent spec.extraConfig rather than leaving the previously-projected value +// pinned. +func TestReconcileHorizon_ExtraConfigClearedProjectsNil(t *testing.T) { + g := NewGomegaWithT(t) + cp := horizonControlPlane() + cp.Spec.Services.Horizon.ExtraConfig = map[string]apiextensionsv1.JSON{ + "SESSION_TIMEOUT": {Raw: []byte("3600")}, + } + r := newHorizonTestReconciler(t, cp) + ctx := context.Background() + + _, err := r.reconcileHorizon(ctx, cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(getProjectedHorizon(t, r.Client, cp).Spec.ExtraConfig).NotTo(BeEmpty()) + + cp.Spec.Services.Horizon.ExtraConfig = nil + _, err = r.reconcileHorizon(ctx, cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(getProjectedHorizon(t, r.Client, cp).Spec.ExtraConfig).To(BeNil(), + "clearing the extraConfig block must revert the child") +} + func TestReconcileHorizon_MirrorsChildReady(t *testing.T) { g := NewGomegaWithT(t) cp := horizonControlPlane() diff --git a/operators/c5c3/internal/controller/reconcile_keystone.go b/operators/c5c3/internal/controller/reconcile_keystone.go index 685f84379..cf5e96de1 100644 --- a/operators/c5c3/internal/controller/reconcile_keystone.go +++ b/operators/c5c3/internal/controller/reconcile_keystone.go @@ -306,6 +306,14 @@ func (r *ControlPlaneReconciler) reconcileKeystone(ctx context.Context, cp *c5c3 keystone.Spec.PolicyOverrides = merged + // Project the merged extraConfig (globalExtraConfig unioned with the + // per-service block, per-service winning key by key). Assigned + // unconditionally, following the revert-on-clear convention: a nil merge keeps + // the SSA-applied intent free of spec.extraConfig, so a direct edit on the + // child stays unowned until a ControlPlane block is set — and clearing the + // ControlPlane block reverts the child rather than pinning the last value. + keystone.Spec.ExtraConfig = c5c3v1alpha1.MergedExtraConfig(cp.Spec.GlobalExtraConfig, cp.Spec.Services.Keystone.ExtraConfig) + // Project the ControlPlane's RESOLVED store selection onto the Keystone child: // the ControlPlane's explicit spec.secretStoreRef when set, otherwise the // operator-provisioned per-tenant store the child shares the namespace with. @@ -370,7 +378,7 @@ func federationProxyImage(cp *c5c3v1alpha1.ControlPlane) *commonv1.ImageSpec { // carries no ordering dependency on reconcileHorizon — which is gated on // KeystoneReady and therefore runs strictly after this projection. func trustedDashboards(cp *c5c3v1alpha1.ControlPlane) []string { - base := horizonPublicEndpoint(cp.Spec.Services.Horizon) + base := cp.Spec.Services.Horizon.DerivedPublicEndpoint() if base == "" { return nil } diff --git a/operators/c5c3/internal/controller/reconcile_keystone_test.go b/operators/c5c3/internal/controller/reconcile_keystone_test.go index 593059b9d..67915ab00 100644 --- a/operators/c5c3/internal/controller/reconcile_keystone_test.go +++ b/operators/c5c3/internal/controller/reconcile_keystone_test.go @@ -329,6 +329,70 @@ func TestReconcileKeystone_PolicyMerge(t *testing.T) { }), "per-service overrides must win, global rules merged in") } +// TestReconcileKeystone_ExtraConfigMerge proves the projected child's +// spec.extraConfig is the key-by-key merge of globalExtraConfig and the +// per-service block: the per-service value wins on an overlapping key, a +// global-only key in the same section survives, and a global-only section is +// carried over — mirroring TestReconcileKeystone_PolicyMerge. +func TestReconcileKeystone_ExtraConfigMerge(t *testing.T) { + g := NewGomegaWithT(t) + + s := keystoneTestScheme(t) + cp := keystoneControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{ + "database": { + "connection_recycle_time": "280", + "max_pool_size": "5", + }, + "DEFAULT": {"debug": "true"}, + } + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{ + "database": {"connection_recycle_time": "600"}, // overrides global + } + c := fake.NewClientBuilder().WithScheme(s).WithObjects(cp).Build() + r := &ControlPlaneReconciler{Client: c, Scheme: s} + + _, err := r.reconcileKeystone(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + + k := getProjectedKeystone(t, c, cp) + g.Expect(k.Spec.ExtraConfig).To(Equal(map[string]map[string]string{ + "database": { + "connection_recycle_time": "600", // per-service wins + "max_pool_size": "5", // global-only key in the same section + }, + "DEFAULT": {"debug": "true"}, // global-only section + }), "per-service extraConfig must win, global keys/sections merged in") +} + +// TestReconcileKeystone_ExtraConfigClearedProjectsNil proves the field is +// assigned unconditionally: clearing both extraConfig blocks reverts the child +// to an absent spec.extraConfig rather than leaving the previously-projected +// value pinned — mirroring TestReconcileKeystone_ClearsFederationProxyImageOverride. +func TestReconcileKeystone_ExtraConfigClearedProjectsNil(t *testing.T) { + g := NewGomegaWithT(t) + + s := keystoneTestScheme(t) + cp := keystoneControlPlane() + cp.Spec.GlobalExtraConfig = map[string]map[string]string{"DEFAULT": {"debug": "true"}} + cp.Spec.Services.Keystone.ExtraConfig = map[string]map[string]string{ + "database": {"connection_recycle_time": "600"}, + } + c := fake.NewClientBuilder().WithScheme(s).WithObjects(cp).Build() + r := &ControlPlaneReconciler{Client: c, Scheme: s} + + _, err := r.reconcileKeystone(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(getProjectedKeystone(t, c, cp).Spec.ExtraConfig).NotTo(BeEmpty()) + + cp.Spec.GlobalExtraConfig = nil + cp.Spec.Services.Keystone.ExtraConfig = nil + _, err = r.reconcileKeystone(context.Background(), cp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(getProjectedKeystone(t, c, cp).Spec.ExtraConfig).To(BeNil(), + "clearing both extraConfig blocks must revert the child") +} + func TestReconcileKeystone_ScheduleConversionWeekly(t *testing.T) { g := NewGomegaWithT(t) diff --git a/operators/glance/go.mod b/operators/glance/go.mod index 89c2f18a1..44cbafead 100644 --- a/operators/glance/go.mod +++ b/operators/glance/go.mod @@ -27,7 +27,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect @@ -68,7 +68,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect diff --git a/operators/glance/go.sum b/operators/glance/go.sum index 32afa7068..29e9b82e1 100644 --- a/operators/glance/go.sum +++ b/operators/glance/go.sum @@ -24,8 +24,7 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= @@ -77,8 +76,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/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/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -106,18 +105,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -146,26 +139,24 @@ 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.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/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/operators/horizon/api/v1alpha1/horizon_webhook.go b/operators/horizon/api/v1alpha1/horizon_webhook.go index 8258d4aec..74421831a 100644 --- a/operators/horizon/api/v1alpha1/horizon_webhook.go +++ b/operators/horizon/api/v1alpha1/horizon_webhook.go @@ -23,12 +23,12 @@ import ( "github.com/c5c3/forge/internal/common/validation" ) -// pythonSettingName matches a valid Python identifier. extraConfig keys are +// PythonSettingName matches a valid Python identifier. extraConfig keys are // rendered verbatim as the left-hand side of a `NAME = ` assignment // in local_settings.py, so a key that is not an identifier could inject // arbitrary statements (an embedded newline) or evade the exact-match // SECRET_KEY guard (a trailing space). Anything outside this set is rejected. -var pythonSettingName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +var PythonSettingName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // HorizonWebhook implements defaulting and validation webhooks for the // Horizon CRD. Client is injected at startup for cluster-scoped resource @@ -247,7 +247,7 @@ func (w *HorizonWebhook) validate(ctx context.Context, h *Horizon) error { )) continue } - if !pythonSettingName.MatchString(name) { + if !PythonSettingName.MatchString(name) { allErrs = append(allErrs, field.Invalid( specPath.Child("extraConfig").Key(name), name, diff --git a/operators/horizon/go.mod b/operators/horizon/go.mod index e72978405..82ff6371c 100644 --- a/operators/horizon/go.mod +++ b/operators/horizon/go.mod @@ -27,7 +27,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect @@ -69,7 +69,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect diff --git a/operators/horizon/go.sum b/operators/horizon/go.sum index 32afa7068..29e9b82e1 100644 --- a/operators/horizon/go.sum +++ b/operators/horizon/go.sum @@ -24,8 +24,7 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= @@ -77,8 +76,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/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/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -106,18 +105,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -146,26 +139,24 @@ 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.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/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/operators/keystone/go.mod b/operators/keystone/go.mod index 45429b21f..0206ba557 100644 --- a/operators/keystone/go.mod +++ b/operators/keystone/go.mod @@ -70,7 +70,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect diff --git a/operators/keystone/go.sum b/operators/keystone/go.sum index 73c29abdc..aa42cc24a 100644 --- a/operators/keystone/go.sum +++ b/operators/keystone/go.sum @@ -24,8 +24,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -81,8 +79,8 @@ github.com/gophercloud/gophercloud/v2 v2.13.0 h1:yEyJG+kABd8x2ttTqLsomihU6Kg2Yhe github.com/gophercloud/gophercloud/v2 v2.13.0/go.mod h1:KZRLVs6gcoy/pEFdkZqFjdYqnS0emMHv66UqdM5lMjU= 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/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/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -110,18 +108,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -150,26 +142,24 @@ 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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.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/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/tests/e2e/c5c3/invalid-cr/58-global-extraconfig-unknown-option.yaml b/tests/e2e/c5c3/invalid-cr/58-global-extraconfig-unknown-option.yaml new file mode 100644 index 000000000..0e2a08b80 --- /dev/null +++ b/tests/e2e/c5c3/invalid-cr/58-global-extraconfig-unknown-option.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# spec.globalExtraConfig sets an unknown option (providr, a typo of the +# keystone [token] provider option) in a known section: rejected by the webhook +# because it is absent from the keystone 2025.2 option catalog. The finding is +# attributed back to the global block that carried it, so the message names +# spec.globalExtraConfig[token][providr]. +apiVersion: c5c3.io/v1alpha1 +kind: ControlPlane +metadata: + name: cp-global-extraconfig-unknown +spec: + openStackRelease: "2025.2" + globalExtraConfig: + token: + providr: fernet + infrastructure: + database: + host: db.example.com + database: openstack + secretRef: + name: db-creds + cache: + backend: dogpile.cache.pymemcache + servers: + - mc:11211 + services: + keystone: + mode: Managed + korc: + adminCredential: + cloudCredentialsRef: + cloudName: admin + passwordSecretRef: + name: external-admin + key: password diff --git a/tests/e2e/c5c3/invalid-cr/59-glance-extraconfig-unknown-option.yaml b/tests/e2e/c5c3/invalid-cr/59-glance-extraconfig-unknown-option.yaml new file mode 100644 index 000000000..038ca40da --- /dev/null +++ b/tests/e2e/c5c3/invalid-cr/59-glance-extraconfig-unknown-option.yaml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# services.glance.extraConfig sets an unknown option (default_backend_typo) in +# the known [glance_store] section: rejected by the webhook because it is absent +# from the glance 2025.2 option catalog. The rest of the glance block is valid, so +# the ONLY violation is the per-service catalog check. +apiVersion: c5c3.io/v1alpha1 +kind: ControlPlane +metadata: + name: cp-glance-extraconfig-unknown +spec: + openStackRelease: "2025.2" + infrastructure: + database: + host: db.example.com + database: openstack + secretRef: + name: db-creds + cache: + backend: dogpile.cache.pymemcache + servers: + - mc:11211 + services: + keystone: + mode: Managed + glance: + backends: + - name: primary + type: S3 + isDefault: true + s3: + endpoint: https://s3.example.com + bucket: images + credentialsSecretRef: + name: glance-s3-creds + extraConfig: + glance_store: + default_backend_typo: rbd + korc: + adminCredential: + cloudCredentialsRef: + cloudName: admin + passwordSecretRef: + name: external-admin + key: password diff --git a/tests/e2e/c5c3/invalid-cr/60-keystone-extraconfig-in-external.yaml b/tests/e2e/c5c3/invalid-cr/60-keystone-extraconfig-in-external.yaml new file mode 100644 index 000000000..1473b31f9 --- /dev/null +++ b/tests/e2e/c5c3/invalid-cr/60-keystone-extraconfig-in-external.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# services.keystone.extraConfig in External mode violates the CEL rule: no +# Keystone workload is deployed, so there is no child config the merged block +# would project. The value is otherwise a shape-valid INI block, so the ONLY +# violation is the External-mode forbid. +apiVersion: c5c3.io/v1alpha1 +kind: ControlPlane +metadata: + name: cp-external-extraconfig +spec: + openStackRelease: "2025.2" + services: + keystone: + mode: External + external: + authURL: https://keystone.example.com/v3 + extraConfig: + DEFAULT: + debug: "true" + korc: + adminCredential: + cloudCredentialsRef: + cloudName: admin + passwordSecretRef: + name: external-admin + key: password diff --git a/tests/e2e/c5c3/invalid-cr/61-horizon-extraconfig-secret-key.yaml b/tests/e2e/c5c3/invalid-cr/61-horizon-extraconfig-secret-key.yaml new file mode 100644 index 000000000..303f09bf1 --- /dev/null +++ b/tests/e2e/c5c3/invalid-cr/61-horizon-extraconfig-secret-key.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# SECRET_KEY in services.horizon.extraConfig is forbidden by the webhook +# (Family A, unconditional): it is managed via services.horizon.secretKeyRef, and +# an override in extraConfig would collide with the operator-projected Django +# session key. +apiVersion: c5c3.io/v1alpha1 +kind: ControlPlane +metadata: + name: cp-horizon-extraconfig-secret-key +spec: + openStackRelease: "2025.2" + infrastructure: + database: + host: db.example.com + database: openstack + secretRef: + name: db-creds + cache: + backend: dogpile.cache.pymemcache + servers: + - mc:11211 + services: + keystone: + mode: Managed + horizon: + extraConfig: + SECRET_KEY: hunter2 + korc: + adminCredential: + cloudCredentialsRef: + cloudName: admin + passwordSecretRef: + name: external-admin + key: password diff --git a/tests/e2e/c5c3/invalid-cr/_generate.py b/tests/e2e/c5c3/invalid-cr/_generate.py index bc90bd6d2..d05a4201f 100644 --- a/tests/e2e/c5c3/invalid-cr/_generate.py +++ b/tests/e2e/c5c3/invalid-cr/_generate.py @@ -48,11 +48,12 @@ # Canonical ControlPlane scaffold. Any future required field on ControlPlaneSpec # must be added below AND verified against every fixture. Placeholders: -# {name} metadata.name -# {infrastructure} the whole spec.infrastructure block (indent 2) or "" -# {keystone} the spec.services.keystone body (indent 6) or "" for nil -# {horizon} the spec.services.horizon entry (indent 4) or "" -# {glance} the spec.services.glance entry (indent 4) or "" +# {name} metadata.name +# {global_extra_config} the spec.globalExtraConfig block (indent 2) or "" +# {infrastructure} the whole spec.infrastructure block (indent 2) or "" +# {keystone} the spec.services.keystone body (indent 6) or "" for nil +# {horizon} the spec.services.horizon entry (indent 4) or "" +# {glance} the spec.services.glance entry (indent 4) or "" # # korc.adminCredential.applicationCredential is intentionally omitted: the # defaulting webhook materializes it (rotation.mode etc.) before the CRD's @@ -64,7 +65,7 @@ name: {name} spec: openStackRelease: "2025.2" -{infrastructure} services: +{global_extra_config}{infrastructure} services: keystone: {keystone}{horizon}{glance} korc: adminCredential: @@ -145,12 +146,15 @@ class Fixture: infrastructure: str = "" horizon: str = "" glance: str = "" + # The spec.globalExtraConfig block (indent 2, trailing newline) or "". + global_extra_config: str = "" # The spec.korc.serviceAccounts block (indent 4, trailing newline) or "". service_accounts: str = "" def render(self) -> str: body = SCAFFOLD.format( name=self.name, + global_extra_config=self.global_extra_config, infrastructure=self.infrastructure, keystone=self.keystone, horizon=self.horizon, @@ -1041,6 +1045,76 @@ def render(self) -> str: ), infrastructure=MANAGED_INFRA, ), + # --- freeform extraConfig admission (issue #704, create-rejection matrix) --- + Fixture( + filename="58-global-extraconfig-unknown-option.yaml", + comment=( + "spec.globalExtraConfig sets an unknown option (providr, a typo of the\n" + "keystone [token] provider option) in a known section: rejected by the webhook\n" + "because it is absent from the keystone 2025.2 option catalog. The finding is\n" + "attributed back to the global block that carried it, so the message names\n" + "spec.globalExtraConfig[token][providr]." + ), + name="cp-global-extraconfig-unknown", + keystone=" mode: Managed\n", + infrastructure=MANAGED_INFRA, + global_extra_config=( + " globalExtraConfig:\n" + " token:\n" + " providr: fernet\n" + ), + ), + Fixture( + filename="59-glance-extraconfig-unknown-option.yaml", + comment=( + "services.glance.extraConfig sets an unknown option (default_backend_typo) in\n" + "the known [glance_store] section: rejected by the webhook because it is absent\n" + "from the glance 2025.2 option catalog. The rest of the glance block is valid, so\n" + "the ONLY violation is the per-service catalog check." + ), + name="cp-glance-extraconfig-unknown", + keystone=" mode: Managed\n", + infrastructure=MANAGED_INFRA, + glance=( + VALID_GLANCE + + " extraConfig:\n" + + " glance_store:\n" + + " default_backend_typo: rbd\n" + ), + ), + Fixture( + filename="60-keystone-extraconfig-in-external.yaml", + comment=( + "services.keystone.extraConfig in External mode violates the CEL rule: no\n" + "Keystone workload is deployed, so there is no child config the merged block\n" + "would project. The value is otherwise a shape-valid INI block, so the ONLY\n" + "violation is the External-mode forbid." + ), + name="cp-external-extraconfig", + keystone=( + VALID_EXTERNAL_KEYSTONE + + " extraConfig:\n" + + " DEFAULT:\n" + + ' debug: "true"\n' + ), + ), + Fixture( + filename="61-horizon-extraconfig-secret-key.yaml", + comment=( + "SECRET_KEY in services.horizon.extraConfig is forbidden by the webhook\n" + "(Family A, unconditional): it is managed via services.horizon.secretKeyRef, and\n" + "an override in extraConfig would collide with the operator-projected Django\n" + "session key." + ), + name="cp-horizon-extraconfig-secret-key", + keystone=" mode: Managed\n", + infrastructure=MANAGED_INFRA, + horizon=( + " horizon:\n" + " extraConfig:\n" + " SECRET_KEY: hunter2\n" + ), + ), ) diff --git a/tests/e2e/c5c3/invalid-cr/chainsaw-test.yaml b/tests/e2e/c5c3/invalid-cr/chainsaw-test.yaml index 3d85caf95..d3865ceb6 100644 --- a/tests/e2e/c5c3/invalid-cr/chainsaw-test.yaml +++ b/tests/e2e/c5c3/invalid-cr/chainsaw-test.yaml @@ -501,6 +501,41 @@ spec: - check: ($error != null): true (contains($error, 'Dynamic requires the shared database to be managed')): true + # spec.globalExtraConfig sets an unknown option (typo'd key) in a known + # section: rejected by the webhook because it is absent from the keystone + # 2025.2 option catalog, attributed back to the global block that carried it. + - apply: + file: 58-global-extraconfig-unknown-option.yaml + expect: + - check: + ($error != null): true + (contains($error, 'no such option in the keystone 2025.2 option catalog')): true + (contains($error, 'spec.globalExtraConfig[token][providr]')): true + # services.glance.extraConfig sets an unknown option in the known glance_store + # section: rejected by the webhook because it is absent from the glance 2025.2 + # option catalog. + - apply: + file: 59-glance-extraconfig-unknown-option.yaml + expect: + - check: + ($error != null): true + (contains($error, 'no such option in the glance 2025.2 option catalog')): true + # services.keystone.extraConfig in External mode is rejected by the CEL rule: + # no Keystone workload is deployed, so there is no child config to project. + - apply: + file: 60-keystone-extraconfig-in-external.yaml + expect: + - check: + ($error != null): true + (contains($error, 'services.keystone.extraConfig is forbidden when services.keystone.mode is External')): true + # SECRET_KEY in services.horizon.extraConfig is forbidden by the webhook + # (Family A): it is managed via services.horizon.secretKeyRef. + - apply: + file: 61-horizon-extraconfig-secret-key.yaml + expect: + - check: + ($error != null): true + (contains($error, 'SECRET_KEY is managed via')): true --- apiVersion: chainsaw.kyverno.io/v1alpha1 kind: Test diff --git a/tests/e2e/c5c3/invalid-cr/test_generate.py b/tests/e2e/c5c3/invalid-cr/test_generate.py index c84e46790..b4a0874d4 100644 --- a/tests/e2e/c5c3/invalid-cr/test_generate.py +++ b/tests/e2e/c5c3/invalid-cr/test_generate.py @@ -37,7 +37,7 @@ # Number of fixtures emitted by _generate.py. Bumping this value requires adding # the matching Fixture entry AND the matching `file: ` line in # chainsaw-test.yaml. -_EXPECTED_FIXTURE_COUNT = 58 +_EXPECTED_FIXTURE_COUNT = 62 def _load_generator() -> types.ModuleType: