From 6ed3a3268f1ed2448f51f245c2bafc7ba42e3562 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 22 Jul 2026 12:39:57 +0200 Subject: [PATCH 1/7] refactor(horizon): export the Python setting-name pattern Export the package-level regexp that validates extraConfig setting names so it can be reused outside the Horizon webhook package. The ControlPlane webhook in the c5c3 operator will validate services.horizon.extraConfig setting names at admission and must apply the exact identifier pattern the Horizon child webhook enforces, rather than copying the regexp and risking drift. On-behalf-of: @SAP Assisted-by: Claude:claude-fable-5 Signed-off-by: Christian Berendt --- operators/horizon/api/v1alpha1/horizon_webhook.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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, From 135e92c555ccf64020c564bc5d8e2f275759a619 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 22 Jul 2026 12:47:56 +0200 Subject: [PATCH 2/7] feat(c5c3): add extraConfig API fields and merge helper Add free-form service configuration to the ControlPlane CRD: a global INI block spec.globalExtraConfig, per-service INI blocks spec.services.keystone.extraConfig and spec.services.glance.extraConfig, and flat Django settings spec.services.horizon.extraConfig (map[string]JSON). The global block and a service's own INI block are merged key by key: sections are unioned, the per-service value wins per key, and a global key with no per-service counterpart stays effective. The global block is INI-only and never applies to Horizon, which renders flat Django settings. On Keystone the block is forbidden in External mode (new CEL rule, later mirrored by the webhook). Add MergedExtraConfig, the single merge helper both the reconciler and the validating webhook will consume so admission and projection can never drift, normalized to nil on an empty merge. Add the nil-safe ServiceHorizonSpec.DerivedPublicEndpoint, the shared dashboard-URL derivation moved here from the identity-backend projection. Projection and webhook validation land in later packages. Regenerate the deepcopy and CRD artifacts and promote k8s.io/apiextensions-apiserver to a direct require. On-behalf-of: @SAP Assisted-by: Claude:claude-fable-5 Signed-off-by: Christian Berendt --- .../api/v1alpha1/controlplane_extraconfig.go | 55 +++++++ .../v1alpha1/controlplane_extraconfig_test.go | 145 ++++++++++++++++++ .../c5c3/api/v1alpha1/controlplane_types.go | 60 +++++++- .../api/v1alpha1/zz_generated.deepcopy.go | 62 ++++++++ .../crd/bases/c5c3.io_controlplanes.yaml | 53 +++++++ operators/c5c3/go.mod | 4 +- operators/c5c3/go.sum | 16 +- .../crds/c5c3.io_controlplanes.yaml | 53 +++++++ 8 files changed, 429 insertions(+), 19 deletions(-) create mode 100644 operators/c5c3/api/v1alpha1/controlplane_extraconfig.go create mode 100644 operators/c5c3/api/v1alpha1/controlplane_extraconfig_test.go diff --git a/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go b/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go new file mode 100644 index 000000000..5ecbefff1 --- /dev/null +++ b/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "strings" + + "github.com/c5c3/forge/internal/common/config" +) + +// 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 "" +} 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/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..fe80eec69 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 @@ -77,7 +78,6 @@ require ( 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..00d09fd28 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= @@ -156,8 +150,6 @@ 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= 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 From b8e2ba1cd572e19b55296bad91f33ef4f437845e Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 22 Jul 2026 12:55:41 +0200 Subject: [PATCH 3/7] feat(c5c3): project merged extraConfig onto service children Project the ControlPlane's freeform config blocks onto the three service children, each assigned unconditionally so clearing a ControlPlane block reverts the child rather than pinning the last projected value. Keystone and Glance receive the key-by-key merge of globalExtraConfig and their per-service block through the shared MergedExtraConfig helper, so admission and reconciliation agree on the effective INI. Horizon receives its flat Django block deep-copied verbatim (the global INI block never applies to it) so the child never aliases cp.Spec. This projection and the admission-time dashboard-origin check both call the api package's DerivedPublicEndpoint so they derive the URL identically. On-behalf-of: @SAP Assisted-by: Claude:claude-fable-5 Signed-off-by: Christian Berendt --- .../reference/c5c3/controlplane-reconciler.md | 2 +- .../c5c3/api/v1alpha1/controlplane_webhook.go | 2 +- .../api/v1alpha1/controlplane_webhook_test.go | 2 +- .../internal/controller/identity_backends.go | 23 ------- .../controller/identity_backends_test.go | 2 +- .../internal/controller/reconcile_glance.go | 8 +++ .../controller/reconcile_glance_test.go | 59 +++++++++++++++++ .../internal/controller/reconcile_horizon.go | 27 +++++++- .../controller/reconcile_horizon_test.go | 56 ++++++++++++++++ .../internal/controller/reconcile_keystone.go | 10 ++- .../controller/reconcile_keystone_test.go | 64 +++++++++++++++++++ 11 files changed, 226 insertions(+), 29 deletions(-) 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/operators/c5c3/api/v1alpha1/controlplane_webhook.go b/operators/c5c3/api/v1alpha1/controlplane_webhook.go index 5746a9083..4612f3212 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 "+ diff --git a/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go b/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go index 1b71c0e3c..d432a7b7b 100644 --- a/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go +++ b/operators/c5c3/api/v1alpha1/controlplane_webhook_test.go @@ -2056,7 +2056,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{ 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) From 787718d9ff419e470258afa5c0ee6066dfcc324b Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 22 Jul 2026 13:16:00 +0200 Subject: [PATCH 4/7] feat(c5c3): validate ControlPlane extraConfig at admission Validate the ControlPlane's freeform config blocks at admission with two validation families wired into the ControlPlane webhook. Family A (shape and ownership) runs un-gated on every create and update: it rejects malformed section, key, and Horizon setting names, forbids overrides of the operator-owned keys the ControlPlane projects (Glance's keystone_authtoken password, Keystone's Horizon-derived trusted_dashboard, and Horizon's SECRET_KEY / websso / multi-domain settings), and warns on honored-but-owned overrides, attributing every finding to the concrete global or per-service block that carries it. Family B validates the merged INI of each declared INI service against the release option catalog and is gated on update by an input-change check, so a CR whose extraConfig went stale-invalid is never rejected by an unrelated edit such as a replica bump. Family B fails open with a single warning when no catalog resolves for a service, so a digest pin, an unparseable tag, or a release the build ships no catalog for never blocks admission. The webhook also mirrors the CEL rule forbidding services.keystone.extraConfig in External mode. On-behalf-of: @SAP Assisted-by: Claude:claude-fable-5 Signed-off-by: Christian Berendt --- .../api/v1alpha1/controlplane_extraconfig.go | 426 ++++++++++++++ .../c5c3/api/v1alpha1/controlplane_webhook.go | 41 +- .../api/v1alpha1/controlplane_webhook_test.go | 542 ++++++++++++++++++ 3 files changed, 1007 insertions(+), 2 deletions(-) diff --git a/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go b/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go index 5ecbefff1..dc1259864 100644 --- a/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go +++ b/operators/c5c3/api/v1alpha1/controlplane_extraconfig.go @@ -5,9 +5,18 @@ 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 @@ -53,3 +62,420 @@ func (s *ServiceHorizonSpec) DerivedPublicEndpoint() string { } 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_webhook.go b/operators/c5c3/api/v1alpha1/controlplane_webhook.go index 4612f3212..4d25f182b 100644 --- a/operators/c5c3/api/v1alpha1/controlplane_webhook.go +++ b/operators/c5c3/api/v1alpha1/controlplane_webhook.go @@ -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 d432a7b7b..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" @@ -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")) +} From d137e48c343ec7cd6fc3bb05df935b9bb16357f7 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 22 Jul 2026 13:25:54 +0200 Subject: [PATCH 5/7] test(e2e): add ControlPlane extraConfig invalid-cr fixtures Pin the freeform extraConfig admission rejections to deterministic admission failures against a real API server: an unknown option in spec.globalExtraConfig rejected by the keystone option catalog with its provenance path, an unknown option in services.glance.extraConfig rejected by the glance option catalog, services.keystone.extraConfig in External mode rejected by the type-level CEL rule, and SECRET_KEY in services.horizon.extraConfig rejected by the webhook's ownership check. The four numbered fixtures are generated from the canonical scaffold, which gains a globalExtraConfig placeholder that renders empty for every existing fixture so all prior outputs stay byte-identical. On-behalf-of: @SAP Assisted-by: Claude:claude-fable-5 Signed-off-by: Christian Berendt --- .../58-global-extraconfig-unknown-option.yaml | 38 ++++++++ .../59-glance-extraconfig-unknown-option.yaml | 47 ++++++++++ .../60-keystone-extraconfig-in-external.yaml | 29 +++++++ .../61-horizon-extraconfig-secret-key.yaml | 37 ++++++++ tests/e2e/c5c3/invalid-cr/_generate.py | 86 +++++++++++++++++-- tests/e2e/c5c3/invalid-cr/chainsaw-test.yaml | 35 ++++++++ tests/e2e/c5c3/invalid-cr/test_generate.py | 2 +- 7 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/c5c3/invalid-cr/58-global-extraconfig-unknown-option.yaml create mode 100644 tests/e2e/c5c3/invalid-cr/59-glance-extraconfig-unknown-option.yaml create mode 100644 tests/e2e/c5c3/invalid-cr/60-keystone-extraconfig-in-external.yaml create mode 100644 tests/e2e/c5c3/invalid-cr/61-horizon-extraconfig-secret-key.yaml 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: From c0db863bd00fa7c6c267e03642ccf2fb49fc0422 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 22 Jul 2026 13:43:06 +0200 Subject: [PATCH 6/7] docs: document ControlPlane freeform service configuration Add a Free-form service configuration section to the advanced-configuration guide covering spec.globalExtraConfig and the per-service extraConfig blocks: the key-by-key merge with per-service precedence, the Horizon flat-Django special case, the provenance-carrying rejection paths, the External-mode posture, the admission behavior (owned-key warnings and rejections, option-catalog validation, fail-open, and update gating), the revert-on-clear child-field ownership, and the operator-build catalog-skew limitation with its ProjectionRejected condition names. Refresh the stale "not exposed" claims for extraConfig in the feature-pointer table and the standalone section so they cross-link the new material. Extend the ControlPlane CRD reference with a globalExtraConfig row, an extraConfig row on the Keystone, Horizon, and Glance service tables, the External-mode forbid rule for services.keystone.extraConfig, and a new ExtraConfig admission checks subsection documenting the webhook-only shape, ownership, and option-catalog checks. On-behalf-of: @SAP Assisted-by: Claude:claude-fable-5 Signed-off-by: Christian Berendt --- docs/guides/advanced-configuration.md | 159 ++++++++++++++++++++++-- docs/reference/c5c3/controlplane-crd.md | 75 ++++++++++- 2 files changed, 218 insertions(+), 16 deletions(-) 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 From 54025ef74d6f64dac1219ea8c2439bfbacb0fd97 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Wed, 22 Jul 2026 15:11:26 +0200 Subject: [PATCH 7/7] fix(deps): update module golang.org/x/text to v0.39.0 govulncheck fails on GO-2026-5970, an infinite loop on invalid input in golang.org/x/text/unicode/norm, reachable from all five workspace modules and fixed upstream in v0.39.0. The advisory predates this branch: main also requires v0.38.0, but its last CI run skipped the path-filtered govulncheck job, so the finding surfaces on any branch that touches Go files. Bump golang.org/x/text to v0.39.0 in all five modules and tidy. The accompanying go work sync also aligns github.com/go-logr/logr to v1.4.4 in the three modules whose go.mod was stale against the workspace selection; workspace builds already resolved v1.4.4. On-behalf-of: @SAP Assisted-by: Claude:claude-fable-5 Signed-off-by: Christian Berendt --- internal/common/go.mod | 4 ++-- internal/common/go.sum | 27 +++++++++------------------ operators/c5c3/go.mod | 2 +- operators/c5c3/go.sum | 12 ++++++------ operators/glance/go.mod | 4 ++-- operators/glance/go.sum | 27 +++++++++------------------ operators/horizon/go.mod | 4 ++-- operators/horizon/go.sum | 27 +++++++++------------------ operators/keystone/go.mod | 2 +- operators/keystone/go.sum | 26 ++++++++------------------ 10 files changed, 49 insertions(+), 86 deletions(-) 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/go.mod b/operators/c5c3/go.mod index fe80eec69..0079bbd29 100644 --- a/operators/c5c3/go.mod +++ b/operators/c5c3/go.mod @@ -72,7 +72,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/c5c3/go.sum b/operators/c5c3/go.sum index 00d09fd28..560a0fce4 100644 --- a/operators/c5c3/go.sum +++ b/operators/c5c3/go.sum @@ -142,8 +142,8 @@ 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= @@ -154,12 +154,12 @@ 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/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/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=