You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The ControlPlane exposes a curated subset of each service's settings and no freeform configuration. This issue adds a global INI block spec.globalExtraConfig and per-service blocks spec.services.keystone.extraConfig, spec.services.glance.extraConfig, and spec.services.horizon.extraConfig, merges the INI blocks key by key with the per-service block winning, projects the result onto the child resources, and runs the two checks the sibling issues built — the per-release option catalog and the owned-key registry — against the merged result at ControlPlane admission. Every rejection and warning names the block that contributed the offending key, so a platform operator edits the file that actually carries it.
Three decisions were settled with the author during elaboration and are binding for this plan:
Merge semantics are key by key. Sections are unioned; within a section the per-service value wins per key, and global keys without a per-service counterpart stay effective. This matches policy.MergePolicies (internal/common/policy/policy.go:90), which merges the Rules map the same way. A per-service section never replaces a whole global section.
Horizon is in scope, as a per-service block only. Horizon carries flat Django settings (map[string]apiextensionsv1.JSON, operators/horizon/api/v1alpha1/horizon_types.go:243), not INI, so the global INI block never applies to it and no merge happens: the block is projected verbatim. Horizon has an ownership registry but no option catalog (settled in the Meta Issue), so it gets the ownership check only.
The ControlPlane webhook imports the service api packages directly (operators/keystone/api/v1alpha1, operators/glance/api/v1alpha1, operators/horizon/api/v1alpha1) to reach the embedded catalogs and ownership registries. This contradicts the recorded L1 import rule in the DECISION comment at operators/c5c3/api/v1alpha1/controlplane_types.go:182-195 ("the L1 api package imports ONLY commonv1, k8s.io/apimachinery/, k8s.io/api/core/v1, and sigs.k8s.io/controller-runtime/"); that comment must be amended in this change to record the new rule and its rationale — the module dependency has existed since the L2 reconciler landed (operators/c5c3/go.mod requires all three service modules), so only package-level purity is given up, and the catalogs stay single-source instead of being duplicated.
1. API fields
Already exists: ControlPlaneSpec.GlobalPolicyOverrides with per-service precedence on ServiceKeystoneSpec.PolicyOverrides (operators/c5c3/api/v1alpha1/controlplane_types.go:110 and :250) is the naming and precedence template. The child CRs already carry the target fields: ExtraConfig map[string]map[string]string on Keystone (operators/keystone/api/v1alpha1/keystone_types.go:212) and Glance (operators/glance/api/v1alpha1/glance_types.go:188), ExtraConfig map[string]apiextensionsv1.JSON on Horizon (operators/horizon/api/v1alpha1/horizon_types.go:243).
This issue adds, in controlplane_types.go:
GlobalExtraConfig map[string]map[string]string (JSON globalExtraConfig) on ControlPlaneSpec, placed next to GlobalPolicyOverrides. Godoc states: applies to every INI-configured service the ControlPlane declares (Keystone and Glance today), key-by-key merge, per-service block wins per key, never applies to Horizon.
ExtraConfig map[string]map[string]string (JSON extraConfig) on ServiceKeystoneSpec and on ServiceGlanceSpec.
ExtraConfig map[string]apiextensionsv1.JSON (JSON extraConfig) on ServiceHorizonSpec, mirroring the Horizon child's field shape (the c5c3 api package gains a direct import of k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1, already an indirect dependency in operators/c5c3/go.mod).
One CEL rule appended to the ServiceKeystoneSpec XValidation block (controlplane_types.go:206-217): extraConfig is forbidden when mode is External, message "services.keystone.extraConfig is forbidden when services.keystone.mode is External". Glance and Horizon need no rule: services.glance and services.horizon are already forbidden whole in External mode (operators/c5c3/api/v1alpha1/controlplane_webhook.go:1905-1909). globalExtraConfig stays legal in External mode and applies to nothing — the same posture globalPolicyOverrides already has.
The amended DECISION comment from binding decision 3.
2. Merge helper
Already exists: config.MergeDefaults(userConfig, defaults) (internal/common/config/config.go:111) merges two INI maps key by key, user values winning, into a fresh map without mutating its inputs — exactly the decided semantics with userConfig = per-service block and defaults = global block.
This issue adds: an exported helper MergedExtraConfig(global, service map[string]map[string]string) map[string]map[string]string in a new file operators/c5c3/api/v1alpha1/controlplane_extraconfig.go. It returns config.MergeDefaults(service, global), normalized to nil when the result has length zero, so an empty merge projects an absent field rather than an empty map. It lives in the api package because both the reconciler and the validating webhook must call the same function — validating one merge and projecting another is the drift this issue exists to prevent.
3. Projection onto the children
Already exists: the Keystone projection computes policy.MergePolicies(cp.Spec.GlobalPolicyOverrides, cp.Spec.Services.Keystone.PolicyOverrides) and assigns it (operators/c5c3/internal/controller/reconcile_keystone.go:238 and :307); the Glance child spec is built in operators/c5c3/internal/controller/reconcile_glance.go:377-449; the Horizon child is mutated in a create-or-update closure (operators/c5c3/internal/controller/reconcile_horizon.go:242-262). None of the three assigns ExtraConfig today.
This issue adds one unconditional assignment per projection, following the existing convention that clearing a ControlPlane field must revert the child rather than pin the old value (the replicas comment at reconcile_horizon.go:244-250 states the rule):
reconcile_keystone.go: keystone.Spec.ExtraConfig = c5c3v1alpha1.MergedExtraConfig(cp.Spec.GlobalExtraConfig, cp.Spec.Services.Keystone.ExtraConfig), next to the policy assignment at :307.
reconcile_glance.go: glance.Spec.ExtraConfig = c5c3v1alpha1.MergedExtraConfig(cp.Spec.GlobalExtraConfig, cp.Spec.Services.Glance.ExtraConfig), next to glance.Spec.OpenStackRelease at :403.
reconcile_horizon.go: inside the mutate closure, assign a deep copy of cp.Spec.Services.Horizon.ExtraConfig (copy the map and DeepCopy() each apiextensionsv1.JSON value so the child never aliases cp.Spec; nil when the source is nil or empty).
Because MergedExtraConfig yields nil when both blocks are empty, the SSA-applied Keystone/Glance intent then carries no extraConfig field and a value set directly on the child stays unowned — the same behavior PolicyOverrides has today. Once a ControlPlane block is set, the projection owns the field and direct child edits are reverted on the next reconcile.
4. Admission checks in the ControlPlane webhook
Already exists: the shared check module internal/common/config — LookupCatalog (catalog.go:89), CatalogExemptions (catalog.go:109), KeyExemptionsFromRegistry (catalog.go:119), FindUnknownOptions (catalog.go:163), FindOwnedOverrides (ownership.go:57), FindOwnedSettingOverrides (ownership.go:80) — plus the per-service data: Keystone OwnedConfigKeys (operators/keystone/api/v1alpha1/config_ownership.go:33) and OptionCatalogForRelease keyed by image tag (operators/keystone/api/v1alpha1/option_catalog.go:30); Glance OwnedConfigKeys (operators/glance/api/v1alpha1/config_ownership.go:34), CatalogExemptSections (operators/glance/api/v1alpha1/option_catalog.go:33), and OptionCatalogForRelease keyed by spec.openStackRelease (option_catalog.go:38); Horizon OwnedConfigKeys (operators/horizon/api/v1alpha1/config_ownership.go:40), WebSSOSettingNames / MultiDomainSettingNames (operators/horizon/api/v1alpha1/horizon_types.go:93 and :101), and the Python-identifier pattern pythonSettingName (operators/horizon/api/v1alpha1/horizon_webhook.go:31). The ControlPlane webhook entry points are ValidateCreate (controlplane_webhook.go:1199), ValidateUpdate (:1223), and the accumulated validate (:1246).
This issue adds two validation families in controlplane_extraconfig.go, wired into the webhook, plus one small export: rename pythonSettingName to PythonSettingName in the horizon api package (update its two uses in horizon_webhook.go) so the ControlPlane webhook shares the pattern instead of copying it.
Family A — shape and ownership, un-gated (validateExtraConfigOwnership(cp *ControlPlane) (admission.Warnings, field.ErrorList)), runs on every create and every update, mirroring the child webhooks, which run their rejected-key checks un-gated:
Reject an empty section name or empty option key in any of the three INI blocks (mirror of operators/glance/api/v1alpha1/glance_webhook.go:433-455; the CRD schema cannot constrain map keys).
Reject an empty or non-PythonSettingName setting name in services.horizon.extraConfig (mirror of horizon_webhook.go:241-257) — without this, the Horizon child webhook rejects the projection and the ControlPlane wedges in HorizonProjectionRejected.
Reject Rejected-flagged owned keys on the merged per-service result, with field.Forbidden at every contributing block's path:
Glance [keystone_authtoken] password: always (the child rejects it unconditionally, glance_webhook.go:457-475).
Keystone [federation] trusted_dashboard: only when the ControlPlane will project a non-empty trusted-dashboards list — the child gates this rejection on the typed list being non-empty (operators/keystone/api/v1alpha1/keystone_webhook.go:867-869 and :892-907), and the projection fills it from the Horizon block (reconcile_keystone.go:372-378). To share the derivation, move the body of horizonPublicEndpoint (operators/c5c3/internal/controller/identity_backends.go:162-173) into a new method func (s *ServiceHorizonSpec) DerivedPublicEndpoint() string in the api package and make horizonPublicEndpoint delegate to it. The rejection fires exactly when cp.Spec.Services.Horizon.DerivedPublicEndpoint() != ""; with no Horizon block the key stays admissible (an externally-run dashboard doing WebSSO against the managed Keystone is a legitimate use), surfacing only as an owned-key warning.
Horizon SECRET_KEY, every WebSSOSettingNames entry, and every MultiDomainSettingNames entry: always. This is deliberately stricter than the child, which gates the websso/multiDomain collisions on the typed blocks being present (horizon_webhook.go:506, :606-620): the ControlPlane projects spec.websso/spec.multiDomain dynamically from attached identity backends (reconcile_horizon.go:261-262), so whether the collision materializes is not decidable at admission, and admitting the key would plant a runtime HorizonProjectionRejected wedge. The message says the settings are owned by the ControlPlane's identity-backend projection.
Emit one admission warning per Reported (non-Rejected) owned key found on the merged result via FindOwnedOverrides (Keystone/Glance registries) and FindOwnedSettingOverrides (Horizon registry), naming the key, the owning source (OwnedBy), the Impact when set, and the contributing block path(s). Warnings, not errors: the sibling issue settled that owned-key overrides are honored and surfaced, and the child's ExtraConfigHealthy condition still reports them after projection. An OwnedKey that is exempt at the service level (Keystone [cache] enabled, Horizon OPENSTACK_ENDPOINT_TYPE / SECURE_PROXY_SSL_HEADER) is absent from the registries, so it stays silent here too — no new list is introduced.
Family B — option catalog, gated on update (validateExtraConfigCatalogs(cp *ControlPlane) (admission.Warnings, field.ErrorList)), runs always on create, and on update only when controlPlaneExtraConfigCatalogInputsChanged(oldObj, newObj) reports a change — mirroring the child rule that a stale-invalid resource must not be rejected by an unrelated update (keystone_webhook.go:1032-1058). Inputs: globalExtraConfig, services.keystone.extraConfig, services.glance.extraConfig, openStackRelease, services.keystone.image, and the presence of the services.keystone / services.glance blocks (compared with reflect.DeepEqual where maps are involved).
Per declared INI service — Keystone when services.keystone is set and mode is not External, Glance when services.glance is set — the check computes MergedExtraConfig(global, service) and validates it:
Catalog resolution: Keystone via keystonev1alpha1.OptionCatalogForRelease(tag) where tag is services.keystone.image.Tag when the image override is set, else spec.openStackRelease (the same resolution the projection applies, reconcile_keystone.go:185-193); Glance via glancev1alpha1.OptionCatalogForRelease(cp.Spec.OpenStackRelease) (the projection copies exactly that value onto the child, reconcile_glance.go:403).
Fail open: when no catalog resolves (digest-pinned or unparseable Keystone image tag, or a build embedding no catalog for the release), return exactly one warning per affected service and no error, with the two message variants of keystone_webhook.go:960-977 adapted to name the ControlPlane field.
Exemptions: Keystone gets CatalogExemptions{Keys: KeyExemptionsFromRegistry(keystonev1alpha1.OwnedConfigKeys)} and no plugin sections — the projection never sets spec.plugins on the child, so there are none to exempt; Glance gets Sections from glancev1alpha1.CatalogExemptSections plus Keys from its registry.
Each UnknownOption becomes field.Invalid naming the service and release catalog (for example no such option in the glance 2026.1 option catalog); the unknown-section variant must not repeat the child's "declare via spec.plugins" hint (keystone_webhook.go:1000-1001) — the ControlPlane has no plugins field, so the message says plugin-registered sections are not configurable through the ControlPlane. Each DeprecatedOption becomes a warning naming the replacement, as in keystone_webhook.go:1008-1021.
Provenance: findings are computed once on the merged map, then attributed by membership — a finding at (section, key) produces one error (or one warning mention) per block that contains that pair, anchored at the real field path: spec.globalExtraConfig[<section>][<key>] and/or spec.services.<svc>.extraConfig[<section>][<key>]. A key present in both blocks therefore yields two errors, one per path. Because the global block reaches every declared INI service, a global key must be valid in every declared service's catalog: a Keystone-only option in globalExtraConfig is rejected while services.glance is declared, the error path pointing at spec.globalExtraConfig and the message naming the Glance catalog — moving the key to services.keystone.extraConfig is the fix the message implies.
Wiring: both families are called from ValidateCreate and ValidateUpdate directly — not from validate (controlplane_webhook.go:1246), whose signature returns no warnings. This mirrors the keystone webhook, whose ValidateCreate computes the catalog warnings and errors itself and folds the errors in alongside validate's (keystone_webhook.go:231-232). Both entry points append the families' warnings to the existing insecurePublicEndpointWarnings result and fold the families' error lists together with w.validate(cp)'s into the single newInvalidIfErrs response; ValidateUpdate additionally passes oldObj into the Family B gate. validateKeystoneMode's External-mode forbidden-field list (controlplane_webhook.go:1856-1898) gains the extraConfig mirror of the new CEL rule.
5. Generated artifacts, docs, and tests
Already exists: CRD generation via make manifests into operators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml, the Helm copy via make sync-crds guarded by make verify-crd-sync (Makefile), deepcopy via make generate; the invalid-CR e2e suite generated from tests/e2e/c5c3/invalid-cr/_generate.py with chainsaw-test.yaml assertions and the make verify-invalid-cr-fixtures gate; webhook unit tests in operators/c5c3/api/v1alpha1/controlplane_webhook_test.go; controller tests in operators/c5c3/internal/controller/reconcile_{keystone,glance,horizon}_test.go; the freeform-configuration guide docs/guides/advanced-configuration.md (its ExtraConfig section currently lives under "Standalone Keystone, without a ControlPlane") and the CRD reference docs/reference/c5c3/controlplane-crd.md.
This issue adds: regenerated deepcopy/CRD/Helm artifacts; new invalid-CR fixtures (global unknown option, per-service unknown option, External-mode extraConfig, Horizon SECRET_KEY) added to _generate.py's FIXTURES with matching chainsaw-test.yaml steps and test_generate.py expectations; webhook unit tests and controller projection tests per the acceptance criteria; a ControlPlane section in docs/guides/advanced-configuration.md documenting both block levels, the key-by-key merge, the Horizon special case, and the provenance in rejection messages; and the four new fields in docs/reference/c5c3/controlplane-crd.md. Docs must carry no issue references (repo rule, enforced style).
One known limitation belongs in the guide text: 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 child webhook remains defense-in-depth for that skew, and a skewed rejection surfaces as KeystoneProjectionRejected / GlanceProjectionRejected on the ControlPlane's conditions.
Motivation
A platform operator running the ControlPlane through GitOps cannot set a single service configuration option today. The freeform field exists only on the child resources, and a direct child edit does not stick once the ControlPlane owns the field — the remaining route is a forge change and a release cycle for what upstream treats as day-to-day tuning.
The two checks the sibling issues delivered — admission-time option validation and the owned-key registry — currently protect only whoever edits service CRs by hand. The ControlPlane is the surface a GitOps deployment actually manages, so until the blocks exist there and the checks run there, that work is invisible to its primary audience. Running the checks against the merged result at ControlPlane admission also closes the failure mode the Meta Issue names: without it, a bad ControlPlane is accepted on submission and fails later during projection, as a child-webhook rejection wedged into a status condition that names a resource the operator never wrote.
Provenance is what keeps two block levels usable. A rejection that names only [DEFAULT] verbose_typo leaves the operator searching the global block and up to three per-service blocks across their GitOps repository; a rejection anchored at spec.globalExtraConfig[DEFAULT][verbose_typo] is fixed in one edit.
User Stories
As a platform operator, I want to set an oslo option once for every service and override it for one service, so that routine tuning lands through GitOps without a forge release.
Setting [database] connection_recycle_time in globalExtraConfig on a ControlPlane declaring Keystone and Glance renders that option into both children's spec.extraConfig.
Adding the same key with a different value under services.glance.extraConfig changes only the Glance child's rendered value; the Keystone child keeps the global value.
As a platform operator, I want a rejected ControlPlane to name the block and key that failed, so that I edit the right file on the first attempt.
A typo in the global block is rejected with an error path starting spec.globalExtraConfig; the same typo in a per-service block is rejected with an error path starting spec.services.<svc>.extraConfig.
As a platform operator, I want to set a Django setting for the dashboard, so that Horizon behavior (for example SESSION_TIMEOUT) is configurable without editing the Horizon CR.
A services.horizon.extraConfig entry appears verbatim in the Horizon child's spec.extraConfig and in the rendered local_settings.py.
Affected Areas
operators/c5c3/api/v1alpha1/controlplane_types.go (four new spec fields, one new CEL rule on ServiceKeystoneSpec, amended L1-import DECISION comment)
operators/c5c3/api/v1alpha1/controlplane_webhook.go (wire both families into validate/ValidateCreate/ValidateUpdate, extend validateKeystoneMode's External-mode list)
operators/c5c3/api/v1alpha1/controlplane_webhook_test.go (unit tests for all admission criteria below)
operators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml, operators/c5c3/helm/c5c3-operator/crds/c5c3.io_controlplanes.yaml (regenerated via make manifests sync-crds)
operators/c5c3/go.mod (k8s.io/apiextensions-apiserver moves from indirect to direct via go mod tidy)
docs/reference/c5c3/controlplane-crd.md (the four new fields)
Acceptance Criteria
API and generated artifacts:
Add globalExtraConfig, services.keystone.extraConfig, services.glance.extraConfig (each map[string]map[string]string), and services.horizon.extraConfig (map[string]apiextensionsv1.JSON) to the ControlPlane spec; kubectl explain the regenerated CRD shows all four.
Add the CEL rule forbidding services.keystone.extraConfig in External mode; applying such a CR against the CRD alone (webhook absent) is rejected with the rule's message.
Amend the DECISION comment at controlplane_types.go:182-195 to record that the api package imports the three service api packages for catalogs and registries.
Run make generate manifests sync-crds; make verify-crd-sync passes.
Merge helper (MergedExtraConfig):
Return the per-service value for a key present in both blocks, and keep global keys with no per-service counterpart.
Return nil when both inputs are nil.
Return nil when both inputs are empty non-nil maps.
Return an independent copy when exactly one input is set — mutating the result never mutates cp.Spec.
Projection:
Project the merged result onto the Keystone child's spec.extraConfig and the Glance child's spec.extraConfig; controller tests assert the merged content for a ControlPlane with both blocks set.
Project services.horizon.extraConfig verbatim (deep-copied) onto the Horizon child's spec.extraConfig.
Assign unconditionally: clearing every ControlPlane block projects nil and the child's field reverts; controller tests cover the set-then-cleared transition for all three children.
Webhook rejections (unit-tested in controlplane_webhook_test.go, each asserting the error's field path):
Reject an unknown option in globalExtraConfig with only Keystone declared, at path spec.globalExtraConfig[<section>][<key>], message naming the keystone catalog and release.
Reject a Keystone-only option in globalExtraConfig when services.glance is also declared, at the spec.globalExtraConfig path, message naming the glance catalog.
Reject an unknown option in services.glance.extraConfig at path spec.services.glance.extraConfig[<section>][<key>].
Reject a key that is unknown and present in both blocks with two errors, one per contributing path.
Reject an unknown section without the child's "declare via spec.plugins" hint; the message states plugin-registered sections are not configurable through the ControlPlane.
Accept a valid option present in both blocks, and accept a whole valid ControlPlane with all four blocks populated (the acceptance base case).
Reject [keystone_authtoken] password in either INI block whenever services.glance is declared, regardless of any other field.
Reject [federation] trusted_dashboard in the merged Keystone result when services.horizon carries a publicEndpoint or a gateway hostname; accept it (with an owned-key warning) when services.horizon is absent.
Reject Horizon SECRET_KEY, any WebSSOSettingNames entry, and any MultiDomainSettingNames entry in services.horizon.extraConfig unconditionally.
Reject an empty section name or empty option key in any INI block, and an empty or non-Python-identifier setting name in the Horizon block.
Reject services.keystone.extraConfig in External mode also via the webhook mirror in validateKeystoneMode.
Webhook warnings (unit-tested, each asserting the warning text):
Warn on a Reported owned key (for example Keystone [DEFAULT] debug in globalExtraConfig), naming the key, OwnedBy, and the contributing block path; the CR is admitted.
Warn on a deprecated option, naming its replacement; the CR is admitted.
Emit exactly one fail-open warning and no error when services.keystone.image is digest-pinned and services.keystone.extraConfig is set (catalog unresolvable from the image).
Emit exactly one fail-open warning per affected service and no error when spec.openStackRelease names a release with no embedded catalog.
Emit no extraConfig warning and no extraConfig error when all four blocks are absent (nil) — the empty path adds nothing to an otherwise-valid CR.
Update gating:
Admit an update that touches no catalog input (for example a replicas bump) on a stored ControlPlane whose extraConfig is no longer catalog-valid.
Re-validate on an update that changes any block, openStackRelease, services.keystone.image, or declares a previously-absent services.glance (which newly exposes the global block to the glance catalog).
Reject an update that adds services.horizon with a publicEndpoint while the stored ControlPlane carries [federation] trusted_dashboard in globalExtraConfig or services.keystone.extraConfig (Family A is un-gated).
End-to-end:
Add four rejection fixtures via _generate.py (global unknown option, per-service unknown option, External-mode extraConfig, Horizon SECRET_KEY) with chainsaw-test.yaml steps asserting the admission messages; make verify-invalid-cr-fixtures passes.
Docs:
Document both block levels, the key-by-key merge with per-service precedence, the Horizon flat-block special case, the provenance-carrying messages, and the operator-build catalog limitation in docs/guides/advanced-configuration.md; document the four fields in docs/reference/c5c3/controlplane-crd.md; neither file gains an issue reference.
Non-Goals
Applying globalExtraConfig to Horizon — Horizon renders flat Django settings, not INI sections; its per-service block is the whole surface.
An option catalog for Horizon — Django settings have no machine-readable registry; settled in the Meta Issue, Horizon gets the ownership check only.
A ControlPlane-level plugin declaration or plugin-section exemption — the projection sets no spec.plugins on any child, so plugin-owned sections are rejected as unknown at ControlPlane admission; a deployment needing plugin sections configures the service CR directly.
Mirroring the children's ExtraConfigHealthy condition into ControlPlane status — the child condition remains the post-projection reporting surface; ControlPlane admission warnings cover submission time.
Changing the report-don't-reject posture for operator-owned keys — this issue renders that settled posture as admission warnings, nothing more.
A ConfigMap indirection for extraConfig (the ConfigMapRef pattern PolicySpec has) — the child fields are inline maps and the ControlPlane mirrors them.
Preventing catalog skew between the c5c3-operator build and a deployed service-operator build — the child webhook stays as defense-in-depth and the projection-rejected conditions surface the mismatch.
Category: feature | Scope: Large
Description
The ControlPlane exposes a curated subset of each service's settings and no freeform configuration. This issue adds a global INI block
spec.globalExtraConfigand per-service blocksspec.services.keystone.extraConfig,spec.services.glance.extraConfig, andspec.services.horizon.extraConfig, merges the INI blocks key by key with the per-service block winning, projects the result onto the child resources, and runs the two checks the sibling issues built — the per-release option catalog and the owned-key registry — against the merged result at ControlPlane admission. Every rejection and warning names the block that contributed the offending key, so a platform operator edits the file that actually carries it.Three decisions were settled with the author during elaboration and are binding for this plan:
policy.MergePolicies(internal/common/policy/policy.go:90), which merges theRulesmap the same way. A per-service section never replaces a whole global section.map[string]apiextensionsv1.JSON,operators/horizon/api/v1alpha1/horizon_types.go:243), not INI, so the global INI block never applies to it and no merge happens: the block is projected verbatim. Horizon has an ownership registry but no option catalog (settled in the Meta Issue), so it gets the ownership check only.operators/keystone/api/v1alpha1,operators/glance/api/v1alpha1,operators/horizon/api/v1alpha1) to reach the embedded catalogs and ownership registries. This contradicts the recorded L1 import rule in the DECISION comment atoperators/c5c3/api/v1alpha1/controlplane_types.go:182-195("the L1 api package imports ONLY commonv1, k8s.io/apimachinery/, k8s.io/api/core/v1, and sigs.k8s.io/controller-runtime/"); that comment must be amended in this change to record the new rule and its rationale — the module dependency has existed since the L2 reconciler landed (operators/c5c3/go.modrequires all three service modules), so only package-level purity is given up, and the catalogs stay single-source instead of being duplicated.1. API fields
Already exists:
ControlPlaneSpec.GlobalPolicyOverrideswith per-service precedence onServiceKeystoneSpec.PolicyOverrides(operators/c5c3/api/v1alpha1/controlplane_types.go:110and:250) is the naming and precedence template. The child CRs already carry the target fields:ExtraConfig map[string]map[string]stringon Keystone (operators/keystone/api/v1alpha1/keystone_types.go:212) and Glance (operators/glance/api/v1alpha1/glance_types.go:188),ExtraConfig map[string]apiextensionsv1.JSONon Horizon (operators/horizon/api/v1alpha1/horizon_types.go:243).This issue adds, in
controlplane_types.go:GlobalExtraConfig map[string]map[string]string(JSONglobalExtraConfig) onControlPlaneSpec, placed next toGlobalPolicyOverrides. Godoc states: applies to every INI-configured service the ControlPlane declares (Keystone and Glance today), key-by-key merge, per-service block wins per key, never applies to Horizon.ExtraConfig map[string]map[string]string(JSONextraConfig) onServiceKeystoneSpecand onServiceGlanceSpec.ExtraConfig map[string]apiextensionsv1.JSON(JSONextraConfig) onServiceHorizonSpec, mirroring the Horizon child's field shape (the c5c3 api package gains a direct import ofk8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1, already an indirect dependency inoperators/c5c3/go.mod).ServiceKeystoneSpecXValidation block (controlplane_types.go:206-217):extraConfigis forbidden whenmodeisExternal, message"services.keystone.extraConfig is forbidden when services.keystone.mode is External". Glance and Horizon need no rule:services.glanceandservices.horizonare already forbidden whole in External mode (operators/c5c3/api/v1alpha1/controlplane_webhook.go:1905-1909).globalExtraConfigstays legal in External mode and applies to nothing — the same postureglobalPolicyOverridesalready has.2. Merge helper
Already exists:
config.MergeDefaults(userConfig, defaults)(internal/common/config/config.go:111) merges two INI maps key by key, user values winning, into a fresh map without mutating its inputs — exactly the decided semantics withuserConfig= per-service block anddefaults= global block.This issue adds: an exported helper
MergedExtraConfig(global, service map[string]map[string]string) map[string]map[string]stringin a new fileoperators/c5c3/api/v1alpha1/controlplane_extraconfig.go. It returnsconfig.MergeDefaults(service, global), normalized tonilwhen the result has length zero, so an empty merge projects an absent field rather than an empty map. It lives in the api package because both the reconciler and the validating webhook must call the same function — validating one merge and projecting another is the drift this issue exists to prevent.3. Projection onto the children
Already exists: the Keystone projection computes
policy.MergePolicies(cp.Spec.GlobalPolicyOverrides, cp.Spec.Services.Keystone.PolicyOverrides)and assigns it (operators/c5c3/internal/controller/reconcile_keystone.go:238and:307); the Glance child spec is built inoperators/c5c3/internal/controller/reconcile_glance.go:377-449; the Horizon child is mutated in a create-or-update closure (operators/c5c3/internal/controller/reconcile_horizon.go:242-262). None of the three assignsExtraConfigtoday.This issue adds one unconditional assignment per projection, following the existing convention that clearing a ControlPlane field must revert the child rather than pin the old value (the replicas comment at
reconcile_horizon.go:244-250states the rule):reconcile_keystone.go:keystone.Spec.ExtraConfig = c5c3v1alpha1.MergedExtraConfig(cp.Spec.GlobalExtraConfig, cp.Spec.Services.Keystone.ExtraConfig), next to the policy assignment at:307.reconcile_glance.go:glance.Spec.ExtraConfig = c5c3v1alpha1.MergedExtraConfig(cp.Spec.GlobalExtraConfig, cp.Spec.Services.Glance.ExtraConfig), next toglance.Spec.OpenStackReleaseat:403.reconcile_horizon.go: inside the mutate closure, assign a deep copy ofcp.Spec.Services.Horizon.ExtraConfig(copy the map andDeepCopy()eachapiextensionsv1.JSONvalue so the child never aliasescp.Spec;nilwhen the source is nil or empty).Because
MergedExtraConfigyieldsnilwhen both blocks are empty, the SSA-applied Keystone/Glance intent then carries noextraConfigfield and a value set directly on the child stays unowned — the same behaviorPolicyOverrideshas today. Once a ControlPlane block is set, the projection owns the field and direct child edits are reverted on the next reconcile.4. Admission checks in the ControlPlane webhook
Already exists: the shared check module
internal/common/config—LookupCatalog(catalog.go:89),CatalogExemptions(catalog.go:109),KeyExemptionsFromRegistry(catalog.go:119),FindUnknownOptions(catalog.go:163),FindOwnedOverrides(ownership.go:57),FindOwnedSettingOverrides(ownership.go:80) — plus the per-service data: KeystoneOwnedConfigKeys(operators/keystone/api/v1alpha1/config_ownership.go:33) andOptionCatalogForReleasekeyed by image tag (operators/keystone/api/v1alpha1/option_catalog.go:30); GlanceOwnedConfigKeys(operators/glance/api/v1alpha1/config_ownership.go:34),CatalogExemptSections(operators/glance/api/v1alpha1/option_catalog.go:33), andOptionCatalogForReleasekeyed byspec.openStackRelease(option_catalog.go:38); HorizonOwnedConfigKeys(operators/horizon/api/v1alpha1/config_ownership.go:40),WebSSOSettingNames/MultiDomainSettingNames(operators/horizon/api/v1alpha1/horizon_types.go:93and:101), and the Python-identifier patternpythonSettingName(operators/horizon/api/v1alpha1/horizon_webhook.go:31). The ControlPlane webhook entry points areValidateCreate(controlplane_webhook.go:1199),ValidateUpdate(:1223), and the accumulatedvalidate(:1246).This issue adds two validation families in
controlplane_extraconfig.go, wired into the webhook, plus one small export: renamepythonSettingNametoPythonSettingNamein the horizon api package (update its two uses inhorizon_webhook.go) so the ControlPlane webhook shares the pattern instead of copying it.Family A — shape and ownership, un-gated (
validateExtraConfigOwnership(cp *ControlPlane) (admission.Warnings, field.ErrorList)), runs on every create and every update, mirroring the child webhooks, which run their rejected-key checks un-gated:operators/glance/api/v1alpha1/glance_webhook.go:433-455; the CRD schema cannot constrain map keys).PythonSettingNamesetting name inservices.horizon.extraConfig(mirror ofhorizon_webhook.go:241-257) — without this, the Horizon child webhook rejects the projection and the ControlPlane wedges inHorizonProjectionRejected.Rejected-flagged owned keys on the merged per-service result, withfield.Forbiddenat every contributing block's path:[keystone_authtoken] password: always (the child rejects it unconditionally,glance_webhook.go:457-475).[federation] trusted_dashboard: only when the ControlPlane will project a non-empty trusted-dashboards list — the child gates this rejection on the typed list being non-empty (operators/keystone/api/v1alpha1/keystone_webhook.go:867-869and:892-907), and the projection fills it from the Horizon block (reconcile_keystone.go:372-378). To share the derivation, move the body ofhorizonPublicEndpoint(operators/c5c3/internal/controller/identity_backends.go:162-173) into a new methodfunc (s *ServiceHorizonSpec) DerivedPublicEndpoint() stringin the api package and makehorizonPublicEndpointdelegate to it. The rejection fires exactly whencp.Spec.Services.Horizon.DerivedPublicEndpoint() != ""; with no Horizon block the key stays admissible (an externally-run dashboard doing WebSSO against the managed Keystone is a legitimate use), surfacing only as an owned-key warning.SECRET_KEY, everyWebSSOSettingNamesentry, and everyMultiDomainSettingNamesentry: always. This is deliberately stricter than the child, which gates the websso/multiDomain collisions on the typed blocks being present (horizon_webhook.go:506,:606-620): the ControlPlane projectsspec.websso/spec.multiDomaindynamically from attached identity backends (reconcile_horizon.go:261-262), so whether the collision materializes is not decidable at admission, and admitting the key would plant a runtimeHorizonProjectionRejectedwedge. The message says the settings are owned by the ControlPlane's identity-backend projection.Reported(non-Rejected) owned key found on the merged result viaFindOwnedOverrides(Keystone/Glance registries) andFindOwnedSettingOverrides(Horizon registry), naming the key, the owning source (OwnedBy), theImpactwhen set, and the contributing block path(s). Warnings, not errors: the sibling issue settled that owned-key overrides are honored and surfaced, and the child'sExtraConfigHealthycondition still reports them after projection. AnOwnedKeythat is exempt at the service level (Keystone[cache] enabled, HorizonOPENSTACK_ENDPOINT_TYPE/SECURE_PROXY_SSL_HEADER) is absent from the registries, so it stays silent here too — no new list is introduced.Family B — option catalog, gated on update (
validateExtraConfigCatalogs(cp *ControlPlane) (admission.Warnings, field.ErrorList)), runs always on create, and on update only whencontrolPlaneExtraConfigCatalogInputsChanged(oldObj, newObj)reports a change — mirroring the child rule that a stale-invalid resource must not be rejected by an unrelated update (keystone_webhook.go:1032-1058). Inputs:globalExtraConfig,services.keystone.extraConfig,services.glance.extraConfig,openStackRelease,services.keystone.image, and the presence of theservices.keystone/services.glanceblocks (compared withreflect.DeepEqualwhere maps are involved).Per declared INI service — Keystone when
services.keystoneis set and mode is not External, Glance whenservices.glanceis set — the check computesMergedExtraConfig(global, service)and validates it:keystonev1alpha1.OptionCatalogForRelease(tag)wheretagisservices.keystone.image.Tagwhen the image override is set, elsespec.openStackRelease(the same resolution the projection applies,reconcile_keystone.go:185-193); Glance viaglancev1alpha1.OptionCatalogForRelease(cp.Spec.OpenStackRelease)(the projection copies exactly that value onto the child,reconcile_glance.go:403).keystone_webhook.go:960-977adapted to name the ControlPlane field.CatalogExemptions{Keys: KeyExemptionsFromRegistry(keystonev1alpha1.OwnedConfigKeys)}and no plugin sections — the projection never setsspec.pluginson the child, so there are none to exempt; Glance getsSectionsfromglancev1alpha1.CatalogExemptSectionsplusKeysfrom its registry.UnknownOptionbecomesfield.Invalidnaming the service and release catalog (for exampleno such option in the glance 2026.1 option catalog); the unknown-section variant must not repeat the child's "declare via spec.plugins" hint (keystone_webhook.go:1000-1001) — the ControlPlane has no plugins field, so the message says plugin-registered sections are not configurable through the ControlPlane. EachDeprecatedOptionbecomes a warning naming the replacement, as inkeystone_webhook.go:1008-1021.(section, key)produces one error (or one warning mention) per block that contains that pair, anchored at the real field path:spec.globalExtraConfig[<section>][<key>]and/orspec.services.<svc>.extraConfig[<section>][<key>]. A key present in both blocks therefore yields two errors, one per path. Because the global block reaches every declared INI service, a global key must be valid in every declared service's catalog: a Keystone-only option inglobalExtraConfigis rejected whileservices.glanceis declared, the error path pointing atspec.globalExtraConfigand the message naming the Glance catalog — moving the key toservices.keystone.extraConfigis the fix the message implies.Wiring: both families are called from
ValidateCreateandValidateUpdatedirectly — not fromvalidate(controlplane_webhook.go:1246), whose signature returns no warnings. This mirrors the keystone webhook, whoseValidateCreatecomputes the catalog warnings and errors itself and folds the errors in alongsidevalidate's (keystone_webhook.go:231-232). Both entry points append the families' warnings to the existinginsecurePublicEndpointWarningsresult and fold the families' error lists together withw.validate(cp)'s into the singlenewInvalidIfErrsresponse;ValidateUpdateadditionally passesoldObjinto the Family B gate.validateKeystoneMode's External-mode forbidden-field list (controlplane_webhook.go:1856-1898) gains theextraConfigmirror of the new CEL rule.5. Generated artifacts, docs, and tests
Already exists: CRD generation via
make manifestsintooperators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml, the Helm copy viamake sync-crdsguarded bymake verify-crd-sync(Makefile), deepcopy viamake generate; the invalid-CR e2e suite generated fromtests/e2e/c5c3/invalid-cr/_generate.pywithchainsaw-test.yamlassertions and themake verify-invalid-cr-fixturesgate; webhook unit tests inoperators/c5c3/api/v1alpha1/controlplane_webhook_test.go; controller tests inoperators/c5c3/internal/controller/reconcile_{keystone,glance,horizon}_test.go; the freeform-configuration guidedocs/guides/advanced-configuration.md(its ExtraConfig section currently lives under "Standalone Keystone, without a ControlPlane") and the CRD referencedocs/reference/c5c3/controlplane-crd.md.This issue adds: regenerated deepcopy/CRD/Helm artifacts; new invalid-CR fixtures (global unknown option, per-service unknown option, External-mode
extraConfig, HorizonSECRET_KEY) added to_generate.py'sFIXTURESwith matchingchainsaw-test.yamlsteps andtest_generate.pyexpectations; webhook unit tests and controller projection tests per the acceptance criteria; a ControlPlane section indocs/guides/advanced-configuration.mddocumenting both block levels, the key-by-key merge, the Horizon special case, and the provenance in rejection messages; and the four new fields indocs/reference/c5c3/controlplane-crd.md. Docs must carry no issue references (repo rule, enforced style).One known limitation belongs in the guide text: 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 child webhook remains defense-in-depth for that skew, and a skewed rejection surfaces as
KeystoneProjectionRejected/GlanceProjectionRejectedon the ControlPlane's conditions.Motivation
A platform operator running the ControlPlane through GitOps cannot set a single service configuration option today. The freeform field exists only on the child resources, and a direct child edit does not stick once the ControlPlane owns the field — the remaining route is a forge change and a release cycle for what upstream treats as day-to-day tuning.
The two checks the sibling issues delivered — admission-time option validation and the owned-key registry — currently protect only whoever edits service CRs by hand. The ControlPlane is the surface a GitOps deployment actually manages, so until the blocks exist there and the checks run there, that work is invisible to its primary audience. Running the checks against the merged result at ControlPlane admission also closes the failure mode the Meta Issue names: without it, a bad ControlPlane is accepted on submission and fails later during projection, as a child-webhook rejection wedged into a status condition that names a resource the operator never wrote.
Provenance is what keeps two block levels usable. A rejection that names only
[DEFAULT] verbose_typoleaves the operator searching the global block and up to three per-service blocks across their GitOps repository; a rejection anchored atspec.globalExtraConfig[DEFAULT][verbose_typo]is fixed in one edit.User Stories
[database] connection_recycle_timeinglobalExtraConfigon a ControlPlane declaring Keystone and Glance renders that option into both children'sspec.extraConfig.services.glance.extraConfigchanges only the Glance child's rendered value; the Keystone child keeps the global value.spec.globalExtraConfig; the same typo in a per-service block is rejected with an error path startingspec.services.<svc>.extraConfig.SESSION_TIMEOUT) is configurable without editing the Horizon CR.services.horizon.extraConfigentry appears verbatim in the Horizon child'sspec.extraConfigand in the renderedlocal_settings.py.Affected Areas
operators/c5c3/api/v1alpha1/controlplane_types.go(four new spec fields, one new CEL rule onServiceKeystoneSpec, amended L1-import DECISION comment)operators/c5c3/api/v1alpha1/controlplane_extraconfig.go(new:MergedExtraConfig,validateExtraConfigOwnership,validateExtraConfigCatalogs,controlPlaneExtraConfigCatalogInputsChanged,DerivedPublicEndpoint)operators/c5c3/api/v1alpha1/controlplane_webhook.go(wire both families intovalidate/ValidateCreate/ValidateUpdate, extendvalidateKeystoneMode's External-mode list)operators/c5c3/api/v1alpha1/controlplane_webhook_test.go(unit tests for all admission criteria below)operators/c5c3/api/v1alpha1/zz_generated.deepcopy.go(regenerated)operators/c5c3/config/crd/bases/c5c3.io_controlplanes.yaml,operators/c5c3/helm/c5c3-operator/crds/c5c3.io_controlplanes.yaml(regenerated viamake manifests sync-crds)operators/c5c3/go.mod(k8s.io/apiextensions-apiservermoves from indirect to direct viago mod tidy)operators/c5c3/internal/controller/reconcile_keystone.go,reconcile_glance.go,reconcile_horizon.go(oneExtraConfigprojection each)operators/c5c3/internal/controller/identity_backends.go(horizonPublicEndpointdelegates to the newDerivedPublicEndpointmethod)operators/c5c3/internal/controller/reconcile_keystone_test.go,reconcile_glance_test.go,reconcile_horizon_test.go(projection assertions)operators/horizon/api/v1alpha1/horizon_webhook.go(exportpythonSettingNameasPythonSettingName, update its uses)tests/e2e/c5c3/invalid-cr/_generate.py,chainsaw-test.yaml,test_generate.py, plus the generated fixture files (four new rejection fixtures)docs/guides/advanced-configuration.md(ControlPlane freeform-configuration section)docs/reference/c5c3/controlplane-crd.md(the four new fields)Acceptance Criteria
API and generated artifacts:
globalExtraConfig,services.keystone.extraConfig,services.glance.extraConfig(eachmap[string]map[string]string), andservices.horizon.extraConfig(map[string]apiextensionsv1.JSON) to the ControlPlane spec;kubectl explainthe regenerated CRD shows all four.services.keystone.extraConfigin External mode; applying such a CR against the CRD alone (webhook absent) is rejected with the rule's message.controlplane_types.go:182-195to record that the api package imports the three service api packages for catalogs and registries.make generate manifests sync-crds;make verify-crd-syncpasses.Merge helper (
MergedExtraConfig):nilwhen both inputs are nil.nilwhen both inputs are empty non-nil maps.cp.Spec.Projection:
spec.extraConfigand the Glance child'sspec.extraConfig; controller tests assert the merged content for a ControlPlane with both blocks set.services.horizon.extraConfigverbatim (deep-copied) onto the Horizon child'sspec.extraConfig.niland the child's field reverts; controller tests cover the set-then-cleared transition for all three children.Webhook rejections (unit-tested in
controlplane_webhook_test.go, each asserting the error's field path):globalExtraConfigwith only Keystone declared, at pathspec.globalExtraConfig[<section>][<key>], message naming the keystone catalog and release.globalExtraConfigwhenservices.glanceis also declared, at thespec.globalExtraConfigpath, message naming the glance catalog.services.glance.extraConfigat pathspec.services.glance.extraConfig[<section>][<key>].[keystone_authtoken] passwordin either INI block wheneverservices.glanceis declared, regardless of any other field.[federation] trusted_dashboardin the merged Keystone result whenservices.horizoncarries apublicEndpointor a gateway hostname; accept it (with an owned-key warning) whenservices.horizonis absent.SECRET_KEY, anyWebSSOSettingNamesentry, and anyMultiDomainSettingNamesentry inservices.horizon.extraConfigunconditionally.services.keystone.extraConfigin External mode also via the webhook mirror invalidateKeystoneMode.Webhook warnings (unit-tested, each asserting the warning text):
Reportedowned key (for example Keystone[DEFAULT] debuginglobalExtraConfig), naming the key,OwnedBy, and the contributing block path; the CR is admitted.services.keystone.imageis digest-pinned andservices.keystone.extraConfigis set (catalog unresolvable from the image).spec.openStackReleasenames a release with no embedded catalog.Update gating:
extraConfigis no longer catalog-valid.openStackRelease,services.keystone.image, or declares a previously-absentservices.glance(which newly exposes the global block to the glance catalog).services.horizonwith apublicEndpointwhile the stored ControlPlane carries[federation] trusted_dashboardinglobalExtraConfigorservices.keystone.extraConfig(Family A is un-gated).End-to-end:
_generate.py(global unknown option, per-service unknown option, External-modeextraConfig, HorizonSECRET_KEY) withchainsaw-test.yamlsteps asserting the admission messages;make verify-invalid-cr-fixturespasses.Docs:
docs/guides/advanced-configuration.md; document the four fields indocs/reference/c5c3/controlplane-crd.md; neither file gains an issue reference.Non-Goals
globalExtraConfigto Horizon — Horizon renders flat Django settings, not INI sections; its per-service block is the whole surface.spec.pluginson any child, so plugin-owned sections are rejected as unknown at ControlPlane admission; a deployment needing plugin sections configures the service CR directly.ExtraConfigHealthycondition into ControlPlane status — the child condition remains the post-projection reporting surface; ControlPlane admission warnings cover submission time.ConfigMapRefpatternPolicySpechas) — the child fields are inline maps and the ControlPlane mirrors them.References
internal/common/config/catalog.go,internal/common/config/ownership.go,internal/common/config/config.go(MergeDefaults).internal/common/policy/policy.go(MergePolicies), consumed atoperators/c5c3/internal/controller/reconcile_keystone.go:238.operators/keystone/api/v1alpha1/keystone_webhook.go(validateExtraConfigOptions,extraConfigCatalogInputsChanged),operators/glance/api/v1alpha1/glance_webhook.go,operators/horizon/api/v1alpha1/horizon_webhook.go.docs/guides/advanced-configuration.md,docs/reference/c5c3/controlplane-crd.md.Elaborated by planwerk-agent with Claude:claude-fable-5