From cc0dc05f87c8ca99169379b08a76980cef9b0533 Mon Sep 17 00:00:00 2001 From: Veronika Fisarova Date: Mon, 15 Dec 2025 15:01:37 +0100 Subject: [PATCH] Application Credential Support Adds the end-to-end support for consuming Keystone ApplicationCredentials (AC) in the watcher-operator, enabling WatcherAPI, WatcherApplier, and WatcherDecisionEngine pods to use AC-based authentication when available. Signed-off-by: Veronika Fisarova --- api/bases/watcher.openstack.org_watchers.yaml | 17 ++ api/go.mod | 8 +- api/go.sum | 10 + api/v1beta1/common_types.go | 5 + api/v1beta1/watcher_webhook.go | 6 + api/v1beta1/watcherapi_types.go | 13 + api/v1beta1/zz_generated.deepcopy.go | 17 ++ .../bases/watcher.openstack.org_watchers.yaml | 17 ++ ...atcher-operator.clusterserviceversion.yaml | 15 ++ go.mod | 2 + go.sum | 4 +- internal/controller/watcher_common.go | 2 + internal/controller/watcher_controller.go | 51 ++++ internal/controller/watcherapi_controller.go | 10 + .../controller/watcherapplier_controller.go | 44 +++ .../watcherdecisionengine_controller.go | 9 + templates/00-default.conf | 16 +- test/functional/watcher_controller_test.go | 253 ++++++++++++++++++ test/functional/watcherapi_controller_test.go | 224 ++++++++++++++++ .../watcherapplier_controller_test.go | 164 ++++++++++++ .../watcherdecisionengine_controller_test.go | 71 +++++ 21 files changed, 953 insertions(+), 5 deletions(-) diff --git a/api/bases/watcher.openstack.org_watchers.yaml b/api/bases/watcher.openstack.org_watchers.yaml index 6ffefcda..e6dd2dcd 100644 --- a/api/bases/watcher.openstack.org_watchers.yaml +++ b/api/bases/watcher.openstack.org_watchers.yaml @@ -56,6 +56,14 @@ spec: replicas: 1 description: APIServiceTemplate - define the watcher-api service properties: + auth: + description: Auth - Parameters related to authentication + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing + Application Credential ID and Secret + type: string + type: object customServiceConfig: description: |- CustomServiceConfig - customize the service config using this parameter to change service defaults, @@ -460,6 +468,15 @@ spec: type: string type: object type: object + auth: + description: Auth - Parameters related to authentication (shared by + all Watcher components) + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object customServiceConfig: description: |- CustomServiceConfig - customize the service config using this parameter to change service defaults, diff --git a/api/go.mod b/api/go.mod index 34645499..07e61aa2 100644 --- a/api/go.mod +++ b/api/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.6 require ( github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251223124749-eedb97238c5f + github.com/openstack-k8s-operators/keystone-operator/api v0.6.1-0.20251206133124-593df0a7a9e1 github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251230215914-6ba873b49a35 k8s.io/api v0.31.14 k8s.io/apimachinery v0.31.14 @@ -18,7 +19,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect 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.0 // indirect @@ -33,6 +33,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gophercloud/gophercloud/v2 v2.8.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -40,6 +41,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openshift/api v3.9.0+incompatible // indirect + github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.1-0.20251122131503-b76943960b6c // indirect + github.com/openstack-k8s-operators/lib-common/modules/storage v0.6.1-0.20251122131503-b76943960b6c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -91,3 +95,5 @@ replace k8s.io/component-base => k8s.io/component-base v0.31.13 //allow-merging replace github.com/rabbitmq/cluster-operator/v2 => github.com/openstack-k8s-operators/rabbitmq-cluster-operator/v2 v2.6.1-0.20250929174222-a0d328fa4dec //allow-merging replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20250627150254-e9823e99808e //allow-merging + +replace github.com/openstack-k8s-operators/keystone-operator/api => github.com/Deydra71/keystone-operator/api v0.0.0-20251211085602-3e1a3e022c81 diff --git a/api/go.sum b/api/go.sum index 595e612f..04b4553f 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,3 +1,5 @@ +github.com/Deydra71/keystone-operator/api v0.0.0-20251211085602-3e1a3e022c81 h1:plax+NFgJJL1SrERyXAnf3jOHRhLTtBlJ2oc7d84EoU= +github.com/Deydra71/keystone-operator/api v0.0.0-20251211085602-3e1a3e022c81/go.mod h1:b98Jl8eyUw8V07l9YiuQnoMlnWC748oV8IhXH15NCC4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -48,6 +50,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/gophercloud/gophercloud/v2 v2.8.0 h1:of2+8tT6+FbEYHfYC8GBu8TXJNsXYSNm9KuvpX7Neqo= +github.com/gophercloud/gophercloud/v2 v2.8.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -78,10 +82,16 @@ github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8 github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/openshift/api v3.9.0+incompatible h1:fJ/KsefYuZAjmrr3+5U9yZIZbTOpVkDDLDLFresAeYs= +github.com/openshift/api v3.9.0+incompatible/go.mod h1:dh9o4Fs58gpFXGSYfnVxGR9PnV53I8TW84pQaJDdGiY= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251223124749-eedb97238c5f h1:xcCGJ/g5vvbWhtEJCbv8UeBneI5yrMawm+CXRsJrJZo= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251223124749-eedb97238c5f/go.mod h1:ex8ou6/3ms6ovR+CMXD6XhTlNakm1GhB6UZgagVRNW8= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251230215914-6ba873b49a35 h1:pF3mJ3nwq6r4qwom+rEWZNquZpcQW/iftHlJ1KPIDsk= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251230215914-6ba873b49a35/go.mod h1:kycZyoe7OZdW1HUghr2nI3N7wSJtNahXf6b/ypD14f4= +github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.1-0.20251122131503-b76943960b6c h1:l7FO+XoQRnD4aT5p/JXVY2uezQLdC7D50KrwrTmzCfg= +github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.1-0.20251122131503-b76943960b6c/go.mod h1:zOX7Y05keiSppIvLabuyh42QHBMhCcoskAtxFRbwXKo= +github.com/openstack-k8s-operators/lib-common/modules/storage v0.6.1-0.20251122131503-b76943960b6c h1:dVIaDL5BeIdJjERGaN/XlcvZVplfkzh0uUfiVUHj/6Q= +github.com/openstack-k8s-operators/lib-common/modules/storage v0.6.1-0.20251122131503-b76943960b6c/go.mod h1:fy1lvz3uuzzh01DKKdgroXvmJgMpJBsvl2r9eTtAll0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/api/v1beta1/common_types.go b/api/v1beta1/common_types.go index 09a711a7..83930602 100644 --- a/api/v1beta1/common_types.go +++ b/api/v1beta1/common_types.go @@ -129,6 +129,11 @@ type WatcherSpecCore struct { // APITimeout for Route and Apache APITimeout *int `json:"apiTimeout"` + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + // Auth - Parameters related to authentication (shared by all Watcher components) + Auth AuthSpec `json:"auth,omitempty"` + // +kubebuilder:validation:Optional // NotificationsBusInstance is the name of the RabbitMqCluster CR to select // the Message Bus Service instance used by the Watcher service to publish and consume notifications diff --git a/api/v1beta1/watcher_webhook.go b/api/v1beta1/watcher_webhook.go index b6971327..aea8cc66 100644 --- a/api/v1beta1/watcher_webhook.go +++ b/api/v1beta1/watcher_webhook.go @@ -20,6 +20,7 @@ import ( "fmt" topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" + keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -65,6 +66,11 @@ func (spec *WatcherSpec) Default() { // Default - set defaults for this WatcherSpecCore spec. func (spec *WatcherSpecCore) Default() { // no validations . Placeholder for defaulting webhook integrated in the OpenStackControlPlane + + // Default ApplicationCredentialSecret to standard AC secret name if not specified + if spec.Auth.ApplicationCredentialSecret == "" { + spec.Auth.ApplicationCredentialSecret = keystonev1.GetACSecretName("watcher") + } } var _ webhook.Validator = &Watcher{} diff --git a/api/v1beta1/watcherapi_types.go b/api/v1beta1/watcherapi_types.go index 00edd68a..73fa5d58 100644 --- a/api/v1beta1/watcherapi_types.go +++ b/api/v1beta1/watcherapi_types.go @@ -91,6 +91,14 @@ type APIOverrideSpec struct { Service map[service.Endpoint]service.RoutedOverrideSpec `json:"service,omitempty"` } +// AuthSpec defines authentication parameters +type AuthSpec struct { + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + // ApplicationCredentialSecret - Secret containing Application Credential ID and Secret + ApplicationCredentialSecret string `json:"applicationCredentialSecret,omitempty"` +} + // WatcherAPITemplate defines the input parameters specified by the user to // create a WatcherAPI via higher level CRDs. type WatcherAPITemplate struct { @@ -112,6 +120,11 @@ type WatcherAPITemplate struct { // +operator-sdk:csv:customresourcedefinitions:type=spec // TLS - Parameters related to the TLS TLS tls.API `json:"tls,omitempty"` + + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + // Auth - Parameters related to authentication + Auth AuthSpec `json:"auth,omitempty"` } //+kubebuilder:object:root=true diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index a3555616..ffc0e836 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -49,6 +49,21 @@ func (in *APIOverrideSpec) DeepCopy() *APIOverrideSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthSpec) DeepCopyInto(out *AuthSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthSpec. +func (in *AuthSpec) DeepCopy() *AuthSpec { + if in == nil { + return nil + } + out := new(AuthSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PasswordSelector) DeepCopyInto(out *PasswordSelector) { *out = *in @@ -224,6 +239,7 @@ func (in *WatcherAPITemplate) DeepCopyInto(out *WatcherAPITemplate) { } in.Override.DeepCopyInto(&out.Override) in.TLS.DeepCopyInto(&out.TLS) + out.Auth = in.Auth } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherAPITemplate. @@ -694,6 +710,7 @@ func (in *WatcherSpecCore) DeepCopyInto(out *WatcherSpecCore) { *out = new(int) **out = **in } + out.Auth = in.Auth if in.NotificationsBusInstance != nil { in, out := &in.NotificationsBusInstance, &out.NotificationsBusInstance *out = new(string) diff --git a/config/crd/bases/watcher.openstack.org_watchers.yaml b/config/crd/bases/watcher.openstack.org_watchers.yaml index 6ffefcda..e6dd2dcd 100644 --- a/config/crd/bases/watcher.openstack.org_watchers.yaml +++ b/config/crd/bases/watcher.openstack.org_watchers.yaml @@ -56,6 +56,14 @@ spec: replicas: 1 description: APIServiceTemplate - define the watcher-api service properties: + auth: + description: Auth - Parameters related to authentication + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing + Application Credential ID and Secret + type: string + type: object customServiceConfig: description: |- CustomServiceConfig - customize the service config using this parameter to change service defaults, @@ -460,6 +468,15 @@ spec: type: string type: object type: object + auth: + description: Auth - Parameters related to authentication (shared by + all Watcher components) + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object customServiceConfig: description: |- CustomServiceConfig - customize the service config using this parameter to change service defaults, diff --git a/config/manifests/bases/watcher-operator.clusterserviceversion.yaml b/config/manifests/bases/watcher-operator.clusterserviceversion.yaml index 0e5f30ed..878de7a1 100644 --- a/config/manifests/bases/watcher-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/watcher-operator.clusterserviceversion.yaml @@ -54,9 +54,24 @@ spec: kind: Watcher name: watchers.watcher.openstack.org specDescriptors: + - description: Auth - Parameters related to authentication + displayName: Auth + path: apiServiceTemplate.auth + - description: ApplicationCredentialSecret - Secret containing Application Credential + ID and Secret + displayName: Application Credential Secret + path: apiServiceTemplate.auth.applicationCredentialSecret - description: TLS - Parameters related to the TLS displayName: TLS path: apiServiceTemplate.tls + - description: Auth - Parameters related to authentication (shared by all Watcher + components) + displayName: Auth + path: auth + - description: ApplicationCredentialSecret - Secret containing Application Credential + ID and Secret + displayName: Application Credential Secret + path: auth.applicationCredentialSecret version: v1beta1 description: The Watcher Operator project displayName: Watcher Operator diff --git a/go.mod b/go.mod index 86623f4a..c9a2719a 100644 --- a/go.mod +++ b/go.mod @@ -142,3 +142,5 @@ replace k8s.io/component-base => k8s.io/component-base v0.31.13 //allow-merging replace github.com/rabbitmq/cluster-operator/v2 => github.com/openstack-k8s-operators/rabbitmq-cluster-operator/v2 v2.6.1-0.20250929174222-a0d328fa4dec //allow-merging replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20250627150254-e9823e99808e //allow-merging + +replace github.com/openstack-k8s-operators/keystone-operator/api => github.com/Deydra71/keystone-operator/api v0.0.0-20251211085602-3e1a3e022c81 diff --git a/go.sum b/go.sum index 11b44bae..fddeb82f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Deydra71/keystone-operator/api v0.0.0-20251211085602-3e1a3e022c81 h1:plax+NFgJJL1SrERyXAnf3jOHRhLTtBlJ2oc7d84EoU= +github.com/Deydra71/keystone-operator/api v0.0.0-20251211085602-3e1a3e022c81/go.mod h1:b98Jl8eyUw8V07l9YiuQnoMlnWC748oV8IhXH15NCC4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= @@ -120,8 +122,6 @@ github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e h1:E1OdwSpqWuDPCedyU github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e/go.mod h1:Shkl4HanLwDiiBzakv+con/aMGnVE2MAGvoKp5oyYUo= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251223124749-eedb97238c5f h1:xcCGJ/g5vvbWhtEJCbv8UeBneI5yrMawm+CXRsJrJZo= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251223124749-eedb97238c5f/go.mod h1:ex8ou6/3ms6ovR+CMXD6XhTlNakm1GhB6UZgagVRNW8= -github.com/openstack-k8s-operators/keystone-operator/api v0.6.1-0.20251206133124-593df0a7a9e1 h1:qcgbrF9c0axkaDcFGfIA2wGz8bkaxPuXHj3mdKAyz6M= -github.com/openstack-k8s-operators/keystone-operator/api v0.6.1-0.20251206133124-593df0a7a9e1/go.mod h1:0XsZ6Fc4hTV6a/BBP8+jiH8LR+IP5z9aStdPTDHALNk= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251230215914-6ba873b49a35 h1:pF3mJ3nwq6r4qwom+rEWZNquZpcQW/iftHlJ1KPIDsk= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251230215914-6ba873b49a35/go.mod h1:kycZyoe7OZdW1HUghr2nI3N7wSJtNahXf6b/ypD14f4= github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.1-0.20251122131503-b76943960b6c h1:l7FO+XoQRnD4aT5p/JXVY2uezQLdC7D50KrwrTmzCfg= diff --git a/internal/controller/watcher_common.go b/internal/controller/watcher_common.go index 3cb88b61..41599b8f 100644 --- a/internal/controller/watcher_common.go +++ b/internal/controller/watcher_common.go @@ -36,6 +36,7 @@ const ( tlsAPIPublicField = ".spec.tls.api.public.secretName" topologyField = ".spec.topologyRef.Name" memcachedInstanceField = ".spec.memcachedInstance" + authAppCredSecretField = ".spec.auth.applicationCredentialSecret" //nolint:gosec // G101: Not actual credentials, just field path // service label for cinder endpoint endpointCinder = "cinder" ) @@ -60,6 +61,7 @@ var ( watcherWatchFields = []string{ passwordSecretField, prometheusSecretField, + authAppCredSecretField, } decisionEngineWatchFields = []string{ passwordSecretField, diff --git a/internal/controller/watcher_controller.go b/internal/controller/watcher_controller.go index 9c02e7b4..301fe843 100644 --- a/internal/controller/watcher_controller.go +++ b/internal/controller/watcher_controller.go @@ -804,6 +804,25 @@ func (r *WatcherReconciler) generateServiceConfigDBJobs( "APIPublicPort": fmt.Sprintf("%d", watcher.WatcherPublicPort), } + // Retrieve Application Credential data if configured + if instance.Spec.Auth.ApplicationCredentialSecret != "" { + secret := &corev1.Secret{} + key := types.NamespacedName{Namespace: instance.Namespace, Name: instance.Spec.Auth.ApplicationCredentialSecret} + if err := r.Client.Get(ctx, key, secret); err != nil { + if !k8s_errors.IsNotFound(err) { + Log.Error(err, "Failed to get ApplicationCredential secret", "secret", key) + } + } else { + acID, okID := secret.Data[keystonev1.ACIDSecretKey] + acSecret, okSecret := secret.Data[keystonev1.ACSecretSecretKey] + if okID && len(acID) > 0 && okSecret && len(acSecret) > 0 { + templateParameters["ACID"] = string(acID) + templateParameters["ACSecret"] = string(acSecret) + Log.Info("Using ApplicationCredentials auth", "secret", key) + } + } + } + return GenerateConfigsGeneric(ctx, helper, instance, envVars, templateParameters, customData, labels, true) } @@ -884,6 +903,26 @@ func (r *WatcherReconciler) createSubLevelSecret( watcher.GlobalCustomConfigFileName: instance.Spec.CustomServiceConfig, NotificationURLSelector: string(notificationURLSecret.Data[TransportURLSelector]), } + + // Add Application Credential data if configured + if instance.Spec.Auth.ApplicationCredentialSecret != "" { + acSecret := &corev1.Secret{} + key := types.NamespacedName{Namespace: instance.Namespace, Name: instance.Spec.Auth.ApplicationCredentialSecret} + if err := r.Client.Get(ctx, key, acSecret); err != nil { + if !k8s_errors.IsNotFound(err) { + Log.Error(err, "Failed to get ApplicationCredential secret", "secret", key) + } + } else { + acID, okID := acSecret.Data[keystonev1.ACIDSecretKey] + acSecretData, okSecret := acSecret.Data[keystonev1.ACSecretSecretKey] + if okID && len(acID) > 0 && okSecret && len(acSecretData) > 0 { + data["ACID"] = string(acID) + data["ACSecret"] = string(acSecretData) + Log.Info("Using ApplicationCredentials auth (centralized from parent Watcher CR)", "secret", key) + } + } + } + secretName := instance.Name labels := labels.GetLabels(instance, labels.GetGroupLabel(watcher.ServiceName), map[string]string{}) @@ -1266,6 +1305,18 @@ func (r *WatcherReconciler) SetupWithManager(mgr ctrl.Manager) error { return err } + // index authAppCredSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &watcherv1beta1.Watcher{}, authAppCredSecretField, func(rawObj client.Object) []string { + // Extract the secret name from the spec, if one is provided + cr := rawObj.(*watcherv1beta1.Watcher) + if cr.Spec.Auth.ApplicationCredentialSecret == "" { + return nil + } + return []string{cr.Spec.Auth.ApplicationCredentialSecret} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&watcherv1beta1.Watcher{}). Owns(&watcherv1beta1.WatcherAPI{}). diff --git a/internal/controller/watcherapi_controller.go b/internal/controller/watcherapi_controller.go index f2e38822..9d14e926 100644 --- a/internal/controller/watcherapi_controller.go +++ b/internal/controller/watcherapi_controller.go @@ -472,6 +472,16 @@ func (r *WatcherAPIReconciler) generateServiceConfigs( if string(secret.Data[NotificationURLSelector]) != "" { templateParameters["NotificationURL"] = string(secret.Data[NotificationURLSelector]) } + + // Application Credential data + if acID, ok := secret.Data["ACID"]; ok && len(acID) > 0 { + if acSecretData, ok := secret.Data["ACSecret"]; ok && len(acSecretData) > 0 { + templateParameters["ACID"] = string(acID) + templateParameters["ACSecret"] = string(acSecretData) + Log.Info("Using ApplicationCredentials auth") + } + } + // MTLS if memcachedInstance.GetMemcachedMTLSSecret() != "" { templateParameters["MemcachedAuthCert"] = fmt.Sprint(memcachedv1.CertMountPath()) diff --git a/internal/controller/watcherapplier_controller.go b/internal/controller/watcherapplier_controller.go index aece0d3b..c6e5e0c3 100644 --- a/internal/controller/watcherapplier_controller.go +++ b/internal/controller/watcherapplier_controller.go @@ -435,6 +435,15 @@ func (r *WatcherApplierReconciler) generateServiceConfigs( templateParameters["NotificationURL"] = string(secret.Data[NotificationURLSelector]) } + // Application Credential data + if acID, ok := secret.Data["ACID"]; ok && len(acID) > 0 { + if acSecretData, ok := secret.Data["ACSecret"]; ok && len(acSecretData) > 0 { + templateParameters["ACID"] = string(acID) + templateParameters["ACSecret"] = string(acSecretData) + Log.Info("Using ApplicationCredentials auth") + } + } + // MTLS if memcachedInstance.GetMemcachedMTLSSecret() != "" { templateParameters["MemcachedAuthCert"] = fmt.Sprint(memcachedv1.CertMountPath()) @@ -506,6 +515,36 @@ func (r *WatcherApplierReconciler) SetupWithManager(mgr ctrl.Manager) error { return err } + // Application Credential secret watching function + acSecretFn := func(_ context.Context, o client.Object) []reconcile.Request { + result := []reconcile.Request{} + + // Check if this is a watcher AC secret by name pattern (ac-watcher-secret) + expectedSecretName := keystonev1.GetACSecretName(watcher.ServiceName) + if o.GetName() == expectedSecretName { + // get all WatcherApplier CRs in this namespace + watcherAppliers := &watcherv1beta1.WatcherApplierList{} + listOpts := []client.ListOption{ + client.InNamespace(o.GetNamespace()), + } + if err := r.Client.List(context.Background(), watcherAppliers, listOpts...); err != nil { + return nil + } + + // Enqueue reconcile for all WatcherApplier instances + for _, cr := range watcherAppliers.Items { + result = append(result, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: o.GetNamespace(), + Name: cr.Name, + }, + }) + } + } + + return result + } + return ctrl.NewControllerManagedBy(mgr). For(&watcherv1beta1.WatcherApplier{}). Owns(&corev1.Secret{}). @@ -515,6 +554,11 @@ func (r *WatcherApplierReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(acSecretFn), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Watches( &memcachedv1.Memcached{}, handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), diff --git a/internal/controller/watcherdecisionengine_controller.go b/internal/controller/watcherdecisionengine_controller.go index 9bb31712..0adbd841 100644 --- a/internal/controller/watcherdecisionengine_controller.go +++ b/internal/controller/watcherdecisionengine_controller.go @@ -586,6 +586,15 @@ func (r *WatcherDecisionEngineReconciler) generateServiceConfigs( templateParameters["NotificationURL"] = string(secret.Data[NotificationURLSelector]) } + // Application Credential data + if acID, ok := secret.Data["ACID"]; ok && len(acID) > 0 { + if acSecretData, ok := secret.Data["ACSecret"]; ok && len(acSecretData) > 0 { + templateParameters["ACID"] = string(acID) + templateParameters["ACSecret"] = string(acSecretData) + Log.Info("Using ApplicationCredentials auth") + } + } + // MTLS if memcachedInstance.GetMemcachedMTLSSecret() != "" { templateParameters["MemcachedAuthCert"] = fmt.Sprint(memcachedv1.CertMountPath()) diff --git a/templates/00-default.conf b/templates/00-default.conf index 5f6a0523..5b514078 100644 --- a/templates/00-default.conf +++ b/templates/00-default.conf @@ -48,14 +48,20 @@ memcache_tls_keyfile = {{ .MemcachedAuthKey }} memcache_tls_cafile = {{ .MemcachedAuthCa }} memcache_tls_enabled = true {{ end }} +{{ if (index . "ACID") }} +auth_type = v3applicationcredential +application_credential_id = {{ .ACID }} +application_credential_secret = {{ .ACSecret }} +{{ else }} project_domain_name = Default project_name = service user_domain_name = Default password = {{ .ServicePassword }} username = {{ .ServiceUser }} +auth_type = password +{{ end }} auth_url = {{ .KeystoneAuthURL }} interface = internal -auth_type = password {{ if .CaFilePath }} cafile = {{ .CaFilePath }} {{ end }} @@ -63,14 +69,20 @@ cafile = {{ .CaFilePath }} {{ if (index . "KeystoneAuthURL") }} [watcher_clients_auth] +{{ if (index . "ACID") }} +auth_type = v3applicationcredential +application_credential_id = {{ .ACID }} +application_credential_secret = {{ .ACSecret }} +{{ else }} project_domain_name = Default project_name = service user_domain_name = Default password = {{ .ServicePassword }} username = {{ .ServiceUser }} +auth_type = password +{{ end }} auth_url = {{ .KeystoneAuthURL }} interface = internal -auth_type = password {{ if .CaFilePath }} cafile = {{ .CaFilePath }} {{ end }} diff --git a/test/functional/watcher_controller_test.go b/test/functional/watcher_controller_test.go index 076bc1b2..40a6b089 100644 --- a/test/functional/watcher_controller_test.go +++ b/test/functional/watcher_controller_test.go @@ -18,6 +18,7 @@ import ( mariadbv1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" @@ -1748,4 +1749,256 @@ var _ = Describe("Watcher controller", func() { }) + When("An ApplicationCredential is created for Watcher", func() { + var appCredSecretName string + var appCredID string + var appCredSecret string + + BeforeEach(func() { + appCredSecretName = "ac-watcher-secret" //nolint:gosec + appCredID = "test-watcher-ac-id" + appCredSecret = "test-watcher-ac-secret" //nolint:gosec + + // Create full Watcher with infrastructure + DeferCleanup(th.DeleteInstance, CreateWatcher(watcherTest.Instance, GetDefaultWatcherSpec())) + DeferCleanup(k8sClient.Delete, ctx, CreateWatcherMessageBusSecret(watcherTest.Instance.Namespace, "rabbitmq-secret")) + + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{ + Replicas: ptr.To(int32(1)), + }, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.Watcher.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + watcherTest.Instance.Namespace, + *GetWatcher(watcherTest.Instance).Spec.DatabaseInstance, + corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Port: 3306}}, + }, + ), + ) + + DeferCleanup(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(watcherTest.WatcherAPI.Namespace)) + + DeferCleanup( + k8sClient.Delete, ctx, th.CreateSecret( + types.NamespacedName{Namespace: watcherTest.Instance.Namespace, Name: "metric-storage-prometheus-endpoint"}, + map[string][]byte{ + "host": []byte("prometheus.example.com"), + "port": []byte("9090"), + }, + )) + + mariadb.SimulateMariaDBAccountCompleted(watcherTest.WatcherDatabaseAccount) + mariadb.SimulateMariaDBDatabaseCompleted(watcherTest.WatcherDatabaseName) + infra.SimulateTransportURLReady(watcherTest.WatcherTransportURL) + + DeferCleanup( + k8sClient.Delete, ctx, th.CreateSecret( + types.NamespacedName{Namespace: watcherTest.Instance.Namespace, Name: SecretName}, + map[string][]byte{ + "WatcherPassword": []byte("password"), + }, + )) + + keystone.SimulateKeystoneServiceReady(watcherTest.KeystoneServiceName) + th.SimulateJobSuccess(watcherTest.WatcherDBSync) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherAPIStatefulSet) + keystone.SimulateKeystoneEndpointReady(watcherTest.WatcherKeystoneEndpointName) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherApplierStatefulSet) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherDecisionEngineStatefulSet) + + // Create AC secret with test credentials + acSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: appCredSecretName, + Namespace: watcherTest.Instance.Namespace, + }, + Data: map[string][]byte{ + keystonev1beta1.ACIDSecretKey: []byte(appCredID), + keystonev1beta1.ACSecretSecretKey: []byte(appCredSecret), + }, + } + Expect(k8sClient.Create(ctx, acSecret)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ctx, acSecret) + }) + + It("should put ApplicationCredential data in parent secret", func() { + // Update Watcher CR to reference AC secret + Eventually(func(g Gomega) { + watcher := GetWatcher(watcherTest.Instance) + watcher.Spec.Auth.ApplicationCredentialSecret = appCredSecretName + g.Expect(k8sClient.Update(ctx, watcher)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + + // Verify AC data is in the parent secret (generated by Watcher controller) + Eventually(func(g Gomega) { + parentSecret := th.GetSecret(watcherTest.Watcher) + g.Expect(parentSecret).NotTo(BeNil()) + g.Expect(parentSecret.Data).To(HaveKey("ACID")) + g.Expect(parentSecret.Data).To(HaveKey("ACSecret")) + g.Expect(string(parentSecret.Data["ACID"])).To(Equal(appCredID)) + g.Expect(string(parentSecret.Data["ACSecret"])).To(Equal(appCredSecret)) + }, timeout, interval).Should(Succeed()) + + // Verify child CRs are created and referencing the parent secret + watcherAPI := GetWatcherAPI(watcherTest.WatcherAPI) + Expect(watcherAPI.Spec.Secret).To(Equal("watcher")) + + watcherApplier := GetWatcherApplier(watcherTest.WatcherApplier) + Expect(watcherApplier.Spec.Secret).To(Equal("watcher")) + + watcherDecisionEngine := GetWatcherDecisionEngine(watcherTest.WatcherDecisionEngine) + Expect(watcherDecisionEngine.Spec.Secret).To(Equal("watcher")) + }) + }) + + When("ApplicationCredential is adopted on existing deployment", func() { + var appCredSecretName string + var appCredID string + var appCredSecret string + + BeforeEach(func() { + appCredSecretName = "ac-watcher-secret" //nolint:gosec + + // Create full Watcher with infrastructure (no AC initially) + DeferCleanup(th.DeleteInstance, CreateWatcher(watcherTest.Instance, GetDefaultWatcherSpec())) + DeferCleanup(k8sClient.Delete, ctx, CreateWatcherMessageBusSecret(watcherTest.Instance.Namespace, "rabbitmq-secret")) + + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{ + Replicas: ptr.To(int32(1)), + }, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.Watcher.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + watcherTest.Instance.Namespace, + *GetWatcher(watcherTest.Instance).Spec.DatabaseInstance, + corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Port: 3306}}, + }, + ), + ) + + DeferCleanup(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(watcherTest.WatcherAPI.Namespace)) + + DeferCleanup( + k8sClient.Delete, ctx, th.CreateSecret( + types.NamespacedName{Namespace: watcherTest.Instance.Namespace, Name: "metric-storage-prometheus-endpoint"}, + map[string][]byte{ + "host": []byte("prometheus.example.com"), + "port": []byte("9090"), + }, + )) + + mariadb.SimulateMariaDBAccountCompleted(watcherTest.WatcherDatabaseAccount) + mariadb.SimulateMariaDBDatabaseCompleted(watcherTest.WatcherDatabaseName) + infra.SimulateTransportURLReady(watcherTest.WatcherTransportURL) + + DeferCleanup( + k8sClient.Delete, ctx, th.CreateSecret( + types.NamespacedName{Namespace: watcherTest.Instance.Namespace, Name: SecretName}, + map[string][]byte{ + "WatcherPassword": []byte("password"), + }, + )) + + keystone.SimulateKeystoneServiceReady(watcherTest.KeystoneServiceName) + th.SimulateJobSuccess(watcherTest.WatcherDBSync) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherAPIStatefulSet) + keystone.SimulateKeystoneEndpointReady(watcherTest.WatcherKeystoneEndpointName) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherApplierStatefulSet) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherDecisionEngineStatefulSet) + + // Verify initial state without AC + Eventually(func(g Gomega) { + parentSecret := th.GetSecret(watcherTest.Watcher) + g.Expect(parentSecret).NotTo(BeNil()) + g.Expect(parentSecret.Data).NotTo(HaveKey("ACID")) + g.Expect(parentSecret.Data).NotTo(HaveKey("ACSecret")) + }, timeout, interval).Should(Succeed()) + }) + + It("should adopt, rotate, and remove ApplicationCredential", func() { + // Adopt AC - add AC reference to existing deployment + appCredID = "test-watcher-ac-id-1" + appCredSecret = "test-watcher-ac-secret-1" //nolint:gosec + + acSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: appCredSecretName, + Namespace: watcherTest.Instance.Namespace, + }, + Data: map[string][]byte{ + keystonev1beta1.ACIDSecretKey: []byte(appCredID), + keystonev1beta1.ACSecretSecretKey: []byte(appCredSecret), + }, + } + Expect(k8sClient.Create(ctx, acSecret)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ctx, acSecret) + + Eventually(func(g Gomega) { + watcher := GetWatcher(watcherTest.Instance) + watcher.Spec.Auth.ApplicationCredentialSecret = appCredSecretName + g.Expect(k8sClient.Update(ctx, watcher)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + + // Verify AC data is adopted into parent secret + Eventually(func(g Gomega) { + parentSecret := th.GetSecret(watcherTest.Watcher) + g.Expect(parentSecret).NotTo(BeNil()) + g.Expect(parentSecret.Data).To(HaveKey("ACID")) + g.Expect(parentSecret.Data).To(HaveKey("ACSecret")) + g.Expect(string(parentSecret.Data["ACID"])).To(Equal(appCredID)) + g.Expect(string(parentSecret.Data["ACSecret"])).To(Equal(appCredSecret)) + }, timeout, interval).Should(Succeed()) + + // Rotate AC - update AC secret content + appCredID = "test-watcher-ac-id-2" + appCredSecret = "test-watcher-ac-secret-2" //nolint:gosec + + Eventually(func(g Gomega) { + secret := &corev1.Secret{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: appCredSecretName, + Namespace: watcherTest.Instance.Namespace, + }, secret)).To(Succeed()) + secret.Data[keystonev1beta1.ACIDSecretKey] = []byte(appCredID) + secret.Data[keystonev1beta1.ACSecretSecretKey] = []byte(appCredSecret) + g.Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + + // Verify rotated AC data is in parent secret + Eventually(func(g Gomega) { + parentSecret := th.GetSecret(watcherTest.Watcher) + g.Expect(parentSecret).NotTo(BeNil()) + g.Expect(string(parentSecret.Data["ACID"])).To(Equal(appCredID)) + g.Expect(string(parentSecret.Data["ACSecret"])).To(Equal(appCredSecret)) + }, timeout, interval).Should(Succeed()) + + // Remove AC + Eventually(func(g Gomega) { + watcher := GetWatcher(watcherTest.Instance) + watcher.Spec.Auth.ApplicationCredentialSecret = "" + g.Expect(k8sClient.Update(ctx, watcher)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + + // Verify AC data is removed from parent secret + Eventually(func(g Gomega) { + parentSecret := th.GetSecret(watcherTest.Watcher) + g.Expect(parentSecret).NotTo(BeNil()) + g.Expect(parentSecret.Data).NotTo(HaveKey("ACID")) + g.Expect(parentSecret.Data).NotTo(HaveKey("ACSecret")) + }, timeout, interval).Should(Succeed()) + }) + }) + }) diff --git a/test/functional/watcherapi_controller_test.go b/test/functional/watcherapi_controller_test.go index 3baabdb3..fcdba06b 100644 --- a/test/functional/watcherapi_controller_test.go +++ b/test/functional/watcherapi_controller_test.go @@ -1582,4 +1582,228 @@ heartbeat_in_pthread=false`, }, timeout, interval).Should(Succeed()) }) }) + + When("ApplicationCredential data is in parent secret", func() { + var acID string + var acSecret string + var keystoneAPIName types.NamespacedName + + BeforeEach(func() { + acID = "test-ac-id" + acSecret = "test-ac-secret" + + // Create parent secret in the same schema Watcher controller generates, + // then inject AC data (and password if you care). + secret := CreateInternalTopLevelSecret() + secret.Data["ACID"] = []byte(acID) + secret.Data["ACSecret"] = []byte(acSecret) + secret.Data["WatcherPassword"] = []byte("password") + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ctx, secret) + + // Prometheus secret (WatcherAPI needs it for input ready) + prometheusSecret := th.CreateSecret( + watcherTest.PrometheusSecretName, + map[string][]byte{ + "host": []byte("prometheus.example.com"), + "port": []byte("9090"), + }, + ) + DeferCleanup(k8sClient.Delete, ctx, prometheusSecret) + + // DB infra + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + watcherTest.WatcherAPI.Namespace, + "openstack", + corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 3306}}}, + ), + ) + mariadb.CreateMariaDBAccountAndSecret( + watcherTest.WatcherDatabaseAccount, + mariadbv1.MariaDBAccountSpec{UserName: "watcher"}, + ) + mariadb.CreateMariaDBDatabase( + watcherTest.WatcherAPI.Namespace, + "watcher", + mariadbv1.MariaDBDatabaseSpec{Name: "watcher"}, + ) + mariadb.SimulateMariaDBAccountCompleted(watcherTest.WatcherDatabaseAccount) + mariadb.SimulateMariaDBDatabaseCompleted(watcherTest.WatcherDatabaseName) + + // Create WatcherAPI + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.WatcherAPI, GetDefaultWatcherAPISpec())) + + // Keystone + Memcached infra + keystoneAPIName = keystone.CreateKeystoneAPI(watcherTest.WatcherAPI.Namespace) + DeferCleanup(keystone.DeleteKeystoneAPI, keystoneAPIName) + + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{ + Replicas: ptr.To(int32(1)), + }, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherAPI.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + + // Drive readiness where required by your suite + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherAPIStatefulSet) + keystone.SimulateKeystoneEndpointReady(watcherTest.WatcherKeystoneEndpointName) + }) + + It("should render ApplicationCredential auth in config", func() { + Eventually(func(g Gomega) { + cfgSecret := th.GetSecret(watcherTest.WatcherAPIConfigSecret) + g.Expect(cfgSecret).NotTo(BeNil()) + + conf := string(cfgSecret.Data["00-default.conf"]) + + // AC auth is configured in keystone_authtoken + g.Expect(conf).To(ContainSubstring("[keystone_authtoken]")) + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = " + acID)) + g.Expect(conf).To(ContainSubstring("application_credential_secret = " + acSecret)) + + // AC auth is also configured in watcher_clients_auth + g.Expect(conf).To(ContainSubstring("[watcher_clients_auth]")) + + // Password auth fields should not be present + g.Expect(conf).NotTo(ContainSubstring("auth_type = password")) + g.Expect(conf).NotTo(ContainSubstring("username = watcher")) + g.Expect(conf).NotTo(ContainSubstring("project_name = service")) + }, timeout, interval).Should(Succeed()) + }) + }) + + When("ApplicationCredential is adopted/rotated/removed via parent secret updates", func() { + var keystoneAPIName types.NamespacedName + + BeforeEach(func() { + // Start in password mode + secret := CreateInternalTopLevelSecret() + secret.Data["WatcherPassword"] = []byte("password") + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ctx, secret) + + prometheusSecret := th.CreateSecret( + watcherTest.PrometheusSecretName, + map[string][]byte{ + "host": []byte("prometheus.example.com"), + "port": []byte("9090"), + }, + ) + DeferCleanup(k8sClient.Delete, ctx, prometheusSecret) + + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + watcherTest.WatcherAPI.Namespace, + "openstack", + corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 3306}}}, + ), + ) + mariadb.CreateMariaDBAccountAndSecret( + watcherTest.WatcherDatabaseAccount, + mariadbv1.MariaDBAccountSpec{UserName: "watcher"}, + ) + mariadb.CreateMariaDBDatabase( + watcherTest.WatcherAPI.Namespace, + "watcher", + mariadbv1.MariaDBDatabaseSpec{Name: "watcher"}, + ) + mariadb.SimulateMariaDBAccountCompleted(watcherTest.WatcherDatabaseAccount) + mariadb.SimulateMariaDBDatabaseCompleted(watcherTest.WatcherDatabaseName) + + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.WatcherAPI, GetDefaultWatcherAPISpec())) + + keystoneAPIName = keystone.CreateKeystoneAPI(watcherTest.WatcherAPI.Namespace) + DeferCleanup(keystone.DeleteKeystoneAPI, keystoneAPIName) + + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{ + Replicas: ptr.To(int32(1)), + }, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherAPI.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherAPIStatefulSet) + }) + + It("adopts AC, rotates it, then falls back to password when removed", func() { + // 1) Verify password auth initially + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherAPIConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + + g.Expect(conf).To(ContainSubstring("[keystone_authtoken]")) + g.Expect(conf).To(ContainSubstring("auth_type = password")) + }, timeout, interval).Should(Succeed()) + + // 2) Adopt AC + acID1 := "ac-id-1" + acSecret1 := "ac-secret-1" + Eventually(func(g Gomega) { + sec := &corev1.Secret{} + g.Expect(k8sClient.Get(ctx, watcherTest.InternalTopLevelSecretName, sec)).To(Succeed()) + sec.Data["ACID"] = []byte(acID1) + sec.Data["ACSecret"] = []byte(acSecret1) + g.Expect(k8sClient.Update(ctx, sec)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherAPIConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = " + acID1)) + g.Expect(conf).To(ContainSubstring("application_credential_secret = " + acSecret1)) + g.Expect(conf).NotTo(ContainSubstring("auth_type = password")) + }, timeout, interval).Should(Succeed()) + + // Rotate AC + acID2 := "ac-id-2" + acSecret2 := "ac-secret-2" + Eventually(func(g Gomega) { + sec := &corev1.Secret{} + g.Expect(k8sClient.Get(ctx, watcherTest.InternalTopLevelSecretName, sec)).To(Succeed()) + sec.Data["ACID"] = []byte(acID2) + sec.Data["ACSecret"] = []byte(acSecret2) + g.Expect(k8sClient.Update(ctx, sec)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherAPIConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + + g.Expect(conf).To(ContainSubstring("application_credential_id = " + acID2)) + g.Expect(conf).To(ContainSubstring("application_credential_secret = " + acSecret2)) + }, timeout, interval).Should(Succeed()) + + // Remove AC + Eventually(func(g Gomega) { + sec := &corev1.Secret{} + g.Expect(k8sClient.Get(ctx, watcherTest.InternalTopLevelSecretName, sec)).To(Succeed()) + delete(sec.Data, "ACID") + delete(sec.Data, "ACSecret") + g.Expect(k8sClient.Update(ctx, sec)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherAPIConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + + g.Expect(conf).To(ContainSubstring("auth_type = password")) + g.Expect(conf).NotTo(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).NotTo(ContainSubstring("application_credential_id")) + g.Expect(conf).NotTo(ContainSubstring("application_credential_secret")) + }, timeout, interval).Should(Succeed()) + }) + }) + }) diff --git a/test/functional/watcherapplier_controller_test.go b/test/functional/watcherapplier_controller_test.go index 0e6954b8..1ec1f3a6 100644 --- a/test/functional/watcherapplier_controller_test.go +++ b/test/functional/watcherapplier_controller_test.go @@ -1127,4 +1127,168 @@ heartbeat_in_pthread=false`, }, timeout, interval).Should(Succeed()) }) }) + When("ApplicationCredential data is in parent secret", func() { + var acID string + var acSecret string + + BeforeEach(func() { + acID = "test-ac-id" + acSecret = "test-ac-secret" + + secret := CreateInternalTopLevelSecret() + secret.Data["ACID"] = []byte(acID) + secret.Data["ACSecret"] = []byte(acSecret) + secret.Data["WatcherPassword"] = []byte("password") + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ctx, secret) + + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + watcherTest.WatcherApplier.Namespace, + "openstack", + corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 3306}}}, + ), + ) + mariadb.CreateMariaDBAccountAndSecret( + watcherTest.WatcherDatabaseAccount, + mariadbv1.MariaDBAccountSpec{UserName: "watcher"}, + ) + mariadb.CreateMariaDBDatabase( + watcherTest.WatcherApplier.Namespace, + "watcher", + mariadbv1.MariaDBDatabaseSpec{Name: "watcher"}, + ) + mariadb.SimulateMariaDBAccountCompleted(watcherTest.WatcherDatabaseAccount) + mariadb.SimulateMariaDBDatabaseCompleted(watcherTest.WatcherDatabaseName) + + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + DeferCleanup(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(watcherTest.WatcherApplier.Namespace)) + + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{Replicas: ptr.To(int32(1))}, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherApplier.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherApplierStatefulSet) + }) + + It("should render ApplicationCredential auth in config", func() { + Eventually(func(g Gomega) { + cfgSecret := th.GetSecret(watcherTest.WatcherApplierConfigSecret) + g.Expect(cfgSecret).NotTo(BeNil()) + + conf := string(cfgSecret.Data["00-default.conf"]) + + g.Expect(conf).To(ContainSubstring("[keystone_authtoken]")) + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = " + acID)) + g.Expect(conf).To(ContainSubstring("application_credential_secret = " + acSecret)) + + g.Expect(conf).To(ContainSubstring("[watcher_clients_auth]")) + + g.Expect(conf).NotTo(ContainSubstring("auth_type = password")) + g.Expect(conf).NotTo(ContainSubstring("username = watcher")) + g.Expect(conf).NotTo(ContainSubstring("project_name = service")) + }, timeout, interval).Should(Succeed()) + }) + }) + + When("ApplicationCredential is adopted/rotated/removed via parent secret updates", func() { + BeforeEach(func() { + secret := CreateInternalTopLevelSecret() + secret.Data["WatcherPassword"] = []byte("password") + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ctx, secret) + + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + watcherTest.WatcherApplier.Namespace, + "openstack", + corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 3306}}}, + ), + ) + mariadb.CreateMariaDBAccountAndSecret(watcherTest.WatcherDatabaseAccount, mariadbv1.MariaDBAccountSpec{UserName: "watcher"}) + mariadb.CreateMariaDBDatabase(watcherTest.WatcherApplier.Namespace, "watcher", mariadbv1.MariaDBDatabaseSpec{Name: "watcher"}) + mariadb.SimulateMariaDBAccountCompleted(watcherTest.WatcherDatabaseAccount) + mariadb.SimulateMariaDBDatabaseCompleted(watcherTest.WatcherDatabaseName) + + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + DeferCleanup(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(watcherTest.WatcherApplier.Namespace)) + + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{Replicas: ptr.To(int32(1))}, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherApplier.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherApplierStatefulSet) + }) + + It("adopts AC, rotates it, then falls back to password when removed", func() { + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherApplierConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + g.Expect(conf).To(ContainSubstring("auth_type = password")) + }, timeout, interval).Should(Succeed()) + + // AC Adopt + acID1, acSecret1 := "ac-id-1", "ac-secret-1" + Eventually(func(g Gomega) { + sec := &corev1.Secret{} + g.Expect(k8sClient.Get(ctx, watcherTest.InternalTopLevelSecretName, sec)).To(Succeed()) + sec.Data["ACID"] = []byte(acID1) + sec.Data["ACSecret"] = []byte(acSecret1) + g.Expect(k8sClient.Update(ctx, sec)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherApplierConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = " + acID1)) + g.Expect(conf).To(ContainSubstring("application_credential_secret = " + acSecret1)) + g.Expect(conf).NotTo(ContainSubstring("auth_type = password")) + }, timeout, interval).Should(Succeed()) + + // AC Rotate + acID2, acSecret2 := "ac-id-2", "ac-secret-2" + Eventually(func(g Gomega) { + sec := &corev1.Secret{} + g.Expect(k8sClient.Get(ctx, watcherTest.InternalTopLevelSecretName, sec)).To(Succeed()) + sec.Data["ACID"] = []byte(acID2) + sec.Data["ACSecret"] = []byte(acSecret2) + g.Expect(k8sClient.Update(ctx, sec)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherApplierConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + g.Expect(conf).To(ContainSubstring("application_credential_id = " + acID2)) + g.Expect(conf).To(ContainSubstring("application_credential_secret = " + acSecret2)) + }, timeout, interval).Should(Succeed()) + + // AC Remove + Eventually(func(g Gomega) { + sec := &corev1.Secret{} + g.Expect(k8sClient.Get(ctx, watcherTest.InternalTopLevelSecretName, sec)).To(Succeed()) + delete(sec.Data, "ACID") + delete(sec.Data, "ACSecret") + g.Expect(k8sClient.Update(ctx, sec)).To(Succeed()) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + cfg := th.GetSecret(watcherTest.WatcherApplierConfigSecret) + g.Expect(cfg).NotTo(BeNil()) + conf := string(cfg.Data["00-default.conf"]) + g.Expect(conf).To(ContainSubstring("auth_type = password")) + g.Expect(conf).NotTo(ContainSubstring("auth_type = v3applicationcredential")) + }, timeout, interval).Should(Succeed()) + }) + }) }) diff --git a/test/functional/watcherdecisionengine_controller_test.go b/test/functional/watcherdecisionengine_controller_test.go index 620aa47d..e5703157 100644 --- a/test/functional/watcherdecisionengine_controller_test.go +++ b/test/functional/watcherdecisionengine_controller_test.go @@ -1253,4 +1253,75 @@ heartbeat_in_pthread=false`, }, timeout, interval).Should(Succeed()) }) }) + + When("ApplicationCredential data is in parent secret", func() { + var acID string + var acSecret string + + BeforeEach(func() { + acID = "test-ac-id" + acSecret = "test-ac-secret" + + secret := CreateInternalTopLevelSecret() + secret.Data["ACID"] = []byte(acID) + secret.Data["ACSecret"] = []byte(acSecret) + secret.Data["WatcherPassword"] = []byte("password") + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + DeferCleanup(k8sClient.Delete, ctx, secret) + + prometheusSecret := th.CreateSecret( + watcherTest.PrometheusSecretName, + map[string][]byte{ + "host": []byte("prometheus.example.com"), + "port": []byte("9090"), + }, + ) + DeferCleanup(k8sClient.Delete, ctx, prometheusSecret) + + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + watcherTest.WatcherDecisionEngine.Namespace, + "openstack", + corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 3306}}}, + ), + ) + mariadb.CreateMariaDBAccountAndSecret(watcherTest.WatcherDatabaseAccount, mariadbv1.MariaDBAccountSpec{UserName: "watcher"}) + mariadb.CreateMariaDBDatabase(watcherTest.WatcherDecisionEngine.Namespace, "watcher", mariadbv1.MariaDBDatabaseSpec{Name: "watcher"}) + mariadb.SimulateMariaDBAccountCompleted(watcherTest.WatcherDatabaseAccount) + mariadb.SimulateMariaDBDatabaseCompleted(watcherTest.WatcherDatabaseName) + + DeferCleanup(th.DeleteInstance, CreateWatcherDecisionEngine(watcherTest.WatcherDecisionEngine, GetDefaultWatcherDecisionEngineSpec())) + DeferCleanup(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(watcherTest.WatcherDecisionEngine.Namespace)) + + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{Replicas: ptr.To(int32(1))}, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherDecisionEngine.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherDecisionEngineStatefulSet) + }) + + It("should render ApplicationCredential auth in config", func() { + Eventually(func(g Gomega) { + cfgSecret := th.GetSecret(watcherTest.WatcherDecisionEngineConfigSecret) + g.Expect(cfgSecret).NotTo(BeNil()) + + conf := string(cfgSecret.Data["00-default.conf"]) + + g.Expect(conf).To(ContainSubstring("[keystone_authtoken]")) + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = " + acID)) + g.Expect(conf).To(ContainSubstring("application_credential_secret = " + acSecret)) + + g.Expect(conf).To(ContainSubstring("[watcher_clients_auth]")) + + g.Expect(conf).NotTo(ContainSubstring("auth_type = password")) + g.Expect(conf).NotTo(ContainSubstring("username = watcher")) + g.Expect(conf).NotTo(ContainSubstring("project_name = service")) + }, timeout, interval).Should(Succeed()) + }) + }) + })