From a22b12b025f03bd52394e4f7cd87c8a70d7caedd 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 --- .../watcher.openstack.org_watcherapis.yaml | 9 ++ ....openstack.org_watcherdecisionengines.yaml | 9 ++ api/bases/watcher.openstack.org_watchers.yaml | 17 ++ api/go.mod | 8 +- api/go.sum | 8 + api/v1beta1/common_types.go | 5 + api/v1beta1/watcher_webhook.go | 6 + api/v1beta1/watcherapi_types.go | 18 +++ api/v1beta1/watcherdecisionengine_types.go | 5 + api/v1beta1/zz_generated.deepcopy.go | 19 +++ .../watcher.openstack.org_watcherapis.yaml | 9 ++ ....openstack.org_watcherdecisionengines.yaml | 9 ++ .../bases/watcher.openstack.org_watchers.yaml | 17 ++ go.mod | 2 + go.sum | 4 +- internal/controller/watcher_common.go | 3 + internal/controller/watcher_controller.go | 2 + internal/controller/watcherapi_controller.go | 32 ++++ .../controller/watcherapplier_controller.go | 44 ++++++ .../watcherdecisionengine_controller.go | 31 ++++ templates/00-default.conf | 16 +- test/functional/watcherapi_controller_test.go | 147 ++++++++++++++++++ .../watcherapplier_controller_test.go | 132 ++++++++++++++++ .../watcherdecisionengine_controller_test.go | 147 ++++++++++++++++++ 24 files changed, 694 insertions(+), 5 deletions(-) diff --git a/api/bases/watcher.openstack.org_watcherapis.yaml b/api/bases/watcher.openstack.org_watcherapis.yaml index 13e65742..50249571 100644 --- a/api/bases/watcher.openstack.org_watcherapis.yaml +++ b/api/bases/watcher.openstack.org_watcherapis.yaml @@ -53,6 +53,15 @@ spec: description: APITimeout for Route and Apache minimum: 10 type: integer + auth: + description: Auth - Parameters related to authentication (inherited + from parent Watcher CR) + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object containerImage: description: The service specific Container Image URL (will be set to environmental default if empty) diff --git a/api/bases/watcher.openstack.org_watcherdecisionengines.yaml b/api/bases/watcher.openstack.org_watcherdecisionengines.yaml index 6b654471..63af0bde 100644 --- a/api/bases/watcher.openstack.org_watcherdecisionengines.yaml +++ b/api/bases/watcher.openstack.org_watcherdecisionengines.yaml @@ -49,6 +49,15 @@ spec: spec: description: WatcherDecisionEngineSpec defines the desired state of WatcherDecisionEngine properties: + auth: + description: Auth - Parameters related to authentication (inherited + from parent Watcher CR) + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object containerImage: description: The service specific Container Image URL (will be set to environmental default if empty) 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 e416d999..41f114a2 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.20251217131115-0f117a938d4e + 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.20251215094837-5c05ea64c324 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 0f65bbe3..4d34ad8e 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,14 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +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.20251217131115-0f117a938d4e h1:PIjcXzMMwfvBRFgFpaq/W9tqy0t2cYvcWX+kq6uNtTM= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251217131115-0f117a938d4e/go.mod h1:ex8ou6/3ms6ovR+CMXD6XhTlNakm1GhB6UZgagVRNW8= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251215094837-5c05ea64c324 h1:y2awFehe8MS5YC47UAdmSSBp7qUSq61uWGWPjWEGaRc= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251215094837-5c05ea64c324/go.mod h1:+Me0raWPPdz8gRi9D4z1khmvUgS9vIKAVC8ckg1yJZU= +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/storage v0.6.1-0.20251122131503-b76943960b6c h1:dVIaDL5BeIdJjERGaN/XlcvZVplfkzh0uUfiVUHj/6Q= 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..4a589fda 100644 --- a/api/v1beta1/watcherapi_types.go +++ b/api/v1beta1/watcherapi_types.go @@ -59,6 +59,11 @@ type WatcherAPISpec struct { // +kubebuilder:validation:Minimum=10 // APITimeout for Route and Apache APITimeout int `json:"apiTimeout"` + + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + // Auth - Parameters related to authentication (inherited from parent Watcher CR) + Auth AuthSpec `json:"auth,omitempty"` } // WatcherAPIStatus defines the observed state of WatcherAPI @@ -91,6 +96,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 +125,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/watcherdecisionengine_types.go b/api/v1beta1/watcherdecisionengine_types.go index d65f0d62..fb611c86 100644 --- a/api/v1beta1/watcherdecisionengine_types.go +++ b/api/v1beta1/watcherdecisionengine_types.go @@ -49,6 +49,11 @@ type WatcherDecisionEngineSpec struct { // +operator-sdk:csv:customresourcedefinitions:type=spec // TLS - Parameters related to the TLS TLS tls.Ca `json:"tls,omitempty"` + + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + // Auth - Parameters related to authentication (inherited from parent Watcher CR) + Auth AuthSpec `json:"auth,omitempty"` } // WatcherDecisionEngineStatus defines the observed state of WatcherDecisionEngine diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index a3555616..7829e594 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 @@ -167,6 +182,7 @@ func (in *WatcherAPISpec) DeepCopyInto(out *WatcherAPISpec) { } 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 WatcherAPISpec. @@ -224,6 +240,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. @@ -515,6 +532,7 @@ func (in *WatcherDecisionEngineSpec) DeepCopyInto(out *WatcherDecisionEngineSpec **out = **in } out.TLS = in.TLS + out.Auth = in.Auth } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherDecisionEngineSpec. @@ -694,6 +712,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_watcherapis.yaml b/config/crd/bases/watcher.openstack.org_watcherapis.yaml index 13e65742..50249571 100644 --- a/config/crd/bases/watcher.openstack.org_watcherapis.yaml +++ b/config/crd/bases/watcher.openstack.org_watcherapis.yaml @@ -53,6 +53,15 @@ spec: description: APITimeout for Route and Apache minimum: 10 type: integer + auth: + description: Auth - Parameters related to authentication (inherited + from parent Watcher CR) + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object containerImage: description: The service specific Container Image URL (will be set to environmental default if empty) diff --git a/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml b/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml index 6b654471..63af0bde 100644 --- a/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml +++ b/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml @@ -49,6 +49,15 @@ spec: spec: description: WatcherDecisionEngineSpec defines the desired state of WatcherDecisionEngine properties: + auth: + description: Auth - Parameters related to authentication (inherited + from parent Watcher CR) + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object containerImage: description: The service specific Container Image URL (will be set to environmental default if empty) 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/go.mod b/go.mod index 90579158..39cd024a 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 98eedc98..52bba63b 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.20251217131115-0f117a938d4e h1:PIjcXzMMwfvBRFgFpaq/W9tqy0t2cYvcWX+kq6uNtTM= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251217131115-0f117a938d4e/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.20251215094837-5c05ea64c324 h1:y2awFehe8MS5YC47UAdmSSBp7qUSq61uWGWPjWEGaRc= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251215094837-5c05ea64c324/go.mod h1:+Me0raWPPdz8gRi9D4z1khmvUgS9vIKAVC8ckg1yJZU= 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..3a65a37b 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" ) @@ -49,6 +50,7 @@ var ( caBundleSecretNameField, topologyField, memcachedInstanceField, + authAppCredSecretField, } applierWatchFields = []string{ passwordSecretField, @@ -67,6 +69,7 @@ var ( caBundleSecretNameField, topologyField, memcachedInstanceField, + authAppCredSecretField, } endpointList = []string{ endpointCinder, diff --git a/internal/controller/watcher_controller.go b/internal/controller/watcher_controller.go index 9c02e7b4..de40eeb9 100644 --- a/internal/controller/watcher_controller.go +++ b/internal/controller/watcher_controller.go @@ -936,6 +936,7 @@ func (r *WatcherReconciler) ensureAPI( Override: instance.Spec.APIServiceTemplate.Override, TLS: instance.Spec.APIServiceTemplate.TLS, APITimeout: *instance.Spec.APITimeout, + Auth: instance.Spec.Auth, } // If NodeSelector is not specified in Watcher APIServiceTemplate, the current @@ -1105,6 +1106,7 @@ func (r *WatcherReconciler) ensureDecisionEngine( }, Replicas: instance.Spec.DecisionEngineServiceTemplate.Replicas, TLS: instance.Spec.APIServiceTemplate.TLS.Ca, + Auth: instance.Spec.Auth, } // If NodeSelector is not specified in Watcher DecisionEngineServiceTemplate, the current diff --git a/internal/controller/watcherapi_controller.go b/internal/controller/watcherapi_controller.go index f2e38822..151f7940 100644 --- a/internal/controller/watcherapi_controller.go +++ b/internal/controller/watcherapi_controller.go @@ -472,6 +472,26 @@ func (r *WatcherAPIReconciler) generateServiceConfigs( if string(secret.Data[NotificationURLSelector]) != "" { templateParameters["NotificationURL"] = string(secret.Data[NotificationURLSelector]) } + + // Try to get Application Credential from the secret specified in the CR + 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) + } + } + } + // MTLS if memcachedInstance.GetMemcachedMTLSSecret() != "" { templateParameters["MemcachedAuthCert"] = fmt.Sprint(memcachedv1.CertMountPath()) @@ -929,6 +949,18 @@ func (r *WatcherAPIReconciler) SetupWithManager(mgr ctrl.Manager) error { return err } + // index authAppCredSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &watcherv1beta1.WatcherAPI{}, authAppCredSecretField, func(rawObj client.Object) []string { + // Extract the application credential secret name from the spec, if one is provided + cr := rawObj.(*watcherv1beta1.WatcherAPI) + if cr.Spec.Auth.ApplicationCredentialSecret == "" { + return nil + } + return []string{cr.Spec.Auth.ApplicationCredentialSecret} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&watcherv1beta1.WatcherAPI{}). Owns(&corev1.Secret{}). diff --git a/internal/controller/watcherapplier_controller.go b/internal/controller/watcherapplier_controller.go index aece0d3b..0090aa62 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]) } + // Try to get Application Credential from the keystone-created secret + if acData, err := keystonev1.GetApplicationCredentialFromSecret(ctx, r.Client, instance.Namespace, watcher.ServiceName); err != nil { + Log.Error(err, "Failed to get ApplicationCredential for service", "service", watcher.ServiceName) + } else if acData != nil { + templateParameters["ACID"] = acData.ID + templateParameters["ACSecret"] = acData.Secret + Log.Info("Using ApplicationCredentials auth", "service", watcher.ServiceName) + } + // 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..c9c0f8ea 100644 --- a/internal/controller/watcherdecisionengine_controller.go +++ b/internal/controller/watcherdecisionengine_controller.go @@ -404,6 +404,18 @@ func (r *WatcherDecisionEngineReconciler) SetupWithManager(mgr ctrl.Manager) err return err } + // index authAppCredSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &watcherv1beta1.WatcherDecisionEngine{}, authAppCredSecretField, func(rawObj client.Object) []string { + // Extract the application credential secret name from the spec, if one is provided + cr := rawObj.(*watcherv1beta1.WatcherDecisionEngine) + if cr.Spec.Auth.ApplicationCredentialSecret == "" { + return nil + } + return []string{cr.Spec.Auth.ApplicationCredentialSecret} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&watcherv1beta1.WatcherDecisionEngine{}). Owns(&corev1.Secret{}). @@ -586,6 +598,25 @@ func (r *WatcherDecisionEngineReconciler) generateServiceConfigs( templateParameters["NotificationURL"] = string(secret.Data[NotificationURLSelector]) } + // Try to get Application Credential from the secret specified in the CR + 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) + } + } + } + // 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/watcherapi_controller_test.go b/test/functional/watcherapi_controller_test.go index 3baabdb3..c6771183 100644 --- a/test/functional/watcherapi_controller_test.go +++ b/test/functional/watcherapi_controller_test.go @@ -9,11 +9,14 @@ import ( //revive:disable-next-line:dot-imports memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" + keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" . "github.com/openstack-k8s-operators/lib-common/modules/common/test/helpers" mariadbv1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" + "github.com/openstack-k8s-operators/watcher-operator/internal/watcher" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" ) @@ -1582,4 +1585,148 @@ heartbeat_in_pthread=false`, }, timeout, interval).Should(Succeed()) }) }) + + When("An ApplicationCredential is created for Watcher", func() { + var ( + acName string + acSecretName string + servicePasswordSecret string + passwordSelector string + ) + BeforeEach(func() { + servicePasswordSecret = SecretName + passwordSelector = "WatcherPassword" + + secret := CreateInternalTopLevelSecretNotification() + 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(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(watcherTest.WatcherAPI.Namespace)) + + 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) + + acName = fmt.Sprintf("ac-%s", watcher.ServiceName) + acSecretName = acName + "-secret" + acSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: watcherTest.WatcherAPI.Namespace, + Name: acSecretName, + }, + Data: map[string][]byte{ + keystonev1.ACIDSecretKey: []byte("test-ac-id"), + keystonev1.ACSecretSecretKey: []byte("test-ac-secret"), + }, + } + DeferCleanup(k8sClient.Delete, ctx, acSecret) + Expect(k8sClient.Create(ctx, acSecret)).To(Succeed()) + + // Create AC CR + ac := &keystonev1.KeystoneApplicationCredential{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: watcherTest.WatcherAPI.Namespace, + Name: acName, + }, + Spec: keystonev1.KeystoneApplicationCredentialSpec{ + UserName: watcher.ServiceName, + Secret: servicePasswordSecret, + PasswordSelector: passwordSelector, + Roles: []string{"admin", "member"}, + AccessRules: []keystonev1.ACRule{{Service: "identity", Method: "POST", Path: "/auth/tokens"}}, + ExpirationDays: 30, + GracePeriodDays: 5, + }, + } + DeferCleanup(k8sClient.Delete, ctx, ac) + Expect(k8sClient.Create(ctx, ac)).To(Succeed()) + + // Simulate AC controller updating the status + fetched := &keystonev1.KeystoneApplicationCredential{} + key := types.NamespacedName{Namespace: ac.Namespace, Name: ac.Name} + Expect(k8sClient.Get(ctx, key, fetched)).To(Succeed()) + + fetched.Status.SecretName = acSecretName + now := metav1.Now() + readyCond := condition.Condition{ + Type: condition.ReadyCondition, + Status: corev1.ConditionTrue, + Reason: condition.ReadyReason, + Message: condition.ReadyMessage, + LastTransitionTime: now, + } + fetched.Status.Conditions = condition.Conditions{readyCond} + Expect(k8sClient.Status().Update(ctx, fetched)).To(Succeed()) + + // Create WatcherAPI with auth.applicationCredentialSecret set + spec := GetDefaultWatcherAPISpec() + spec["auth"] = map[string]any{ + "applicationCredentialSecret": acSecretName, + } + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.WatcherAPI, spec)) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherAPIStatefulSet) + keystone.SimulateKeystoneEndpointReady(watcherTest.WatcherKeystoneEndpointName) + }) + + It("should render ApplicationCredential auth in 00-default.conf", func() { + keystone.SimulateKeystoneEndpointReady(watcherTest.WatcherKeystoneEndpointName) + + // Wait for the config to be generated and updated with AC auth + 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 + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = test-ac-id")) + g.Expect(conf).To(ContainSubstring("application_credential_secret = test-ac-secret")) + + // 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()) + }) + }) }) diff --git a/test/functional/watcherapplier_controller_test.go b/test/functional/watcherapplier_controller_test.go index 0e6954b8..2a6314e5 100644 --- a/test/functional/watcherapplier_controller_test.go +++ b/test/functional/watcherapplier_controller_test.go @@ -9,11 +9,14 @@ import ( //revive:disable-next-line:dot-imports memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" + keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" . "github.com/openstack-k8s-operators/lib-common/modules/common/test/helpers" mariadbv1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" + "github.com/openstack-k8s-operators/watcher-operator/internal/watcher" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" ) @@ -1127,4 +1130,133 @@ heartbeat_in_pthread=false`, }, timeout, interval).Should(Succeed()) }) }) + + When("An ApplicationCredential is created for Watcher", func() { + var ( + acName string + acSecretName string + servicePasswordSecret string + passwordSelector string + ) + BeforeEach(func() { + servicePasswordSecret = SecretName + passwordSelector = "WatcherPassword" + + secret := CreateInternalTopLevelSecret() + 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(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) + + // Create AC secret with test credentials + acName = fmt.Sprintf("ac-%s", watcher.ServiceName) + acSecretName = acName + "-secret" + acSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: watcherTest.WatcherApplier.Namespace, + Name: acSecretName, + }, + Data: map[string][]byte{ + "AC_ID": []byte("test-ac-id"), + "AC_SECRET": []byte("test-ac-secret"), + }, + } + DeferCleanup(k8sClient.Delete, ctx, acSecret) + Expect(k8sClient.Create(ctx, acSecret)).To(Succeed()) + + // Create AC CR + ac := &keystonev1.KeystoneApplicationCredential{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: watcherTest.WatcherApplier.Namespace, + Name: acName, + }, + Spec: keystonev1.KeystoneApplicationCredentialSpec{ + UserName: watcher.ServiceName, + Secret: servicePasswordSecret, + PasswordSelector: passwordSelector, + Roles: []string{"admin", "member"}, + AccessRules: []keystonev1.ACRule{{Service: "identity", Method: "POST", Path: "/auth/tokens"}}, + ExpirationDays: 30, + GracePeriodDays: 5, + }, + } + DeferCleanup(k8sClient.Delete, ctx, ac) + Expect(k8sClient.Create(ctx, ac)).To(Succeed()) + + // Simulate AC controller updating the status + fetched := &keystonev1.KeystoneApplicationCredential{} + key := types.NamespacedName{Namespace: ac.Namespace, Name: ac.Name} + Expect(k8sClient.Get(ctx, key, fetched)).To(Succeed()) + + fetched.Status.SecretName = acSecretName + now := metav1.Now() + readyCond := condition.Condition{ + Type: condition.ReadyCondition, + Status: corev1.ConditionTrue, + Reason: condition.ReadyReason, + Message: condition.ReadyMessage, + LastTransitionTime: now, + } + fetched.Status.Conditions = condition.Conditions{readyCond} + Expect(k8sClient.Status().Update(ctx, fetched)).To(Succeed()) + + // Create WatcherApplier + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherApplierStatefulSet) + }) + + It("should render ApplicationCredential auth in 00-default.conf", func() { + // Wait for the config to be generated and updated with AC auth + Eventually(func(g Gomega) { + cfgSecret := th.GetSecret(watcherTest.WatcherApplierConfigSecret) + g.Expect(cfgSecret).NotTo(BeNil()) + + conf := string(cfgSecret.Data["00-default.conf"]) + + // AC auth is configured + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = test-ac-id")) + g.Expect(conf).To(ContainSubstring("application_credential_secret = test-ac-secret")) + + // 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()) + }) + }) }) diff --git a/test/functional/watcherdecisionengine_controller_test.go b/test/functional/watcherdecisionengine_controller_test.go index 620aa47d..b45d0bd5 100644 --- a/test/functional/watcherdecisionengine_controller_test.go +++ b/test/functional/watcherdecisionengine_controller_test.go @@ -9,11 +9,14 @@ import ( //revive:disable-next-line:dot-imports memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" + keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" . "github.com/openstack-k8s-operators/lib-common/modules/common/test/helpers" mariadbv1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" + "github.com/openstack-k8s-operators/watcher-operator/internal/watcher" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" ) @@ -1253,4 +1256,148 @@ heartbeat_in_pthread=false`, }, timeout, interval).Should(Succeed()) }) }) + + When("An ApplicationCredential is created for Watcher", func() { + var ( + acName string + acSecretName string + servicePasswordSecret string + passwordSelector string + ) + BeforeEach(func() { + servicePasswordSecret = SecretName + passwordSelector = "WatcherPassword" + + secret := CreateInternalTopLevelSecret() + 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(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) + + // Create AC secret with test credentials + acName = fmt.Sprintf("ac-%s", watcher.ServiceName) + acSecretName = acName + "-secret" + acSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: watcherTest.WatcherDecisionEngine.Namespace, + Name: acSecretName, + }, + Data: map[string][]byte{ + keystonev1.ACIDSecretKey: []byte("test-ac-id"), + keystonev1.ACSecretSecretKey: []byte("test-ac-secret"), + }, + } + DeferCleanup(k8sClient.Delete, ctx, acSecret) + Expect(k8sClient.Create(ctx, acSecret)).To(Succeed()) + + // Create AC CR + ac := &keystonev1.KeystoneApplicationCredential{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: watcherTest.WatcherDecisionEngine.Namespace, + Name: acName, + }, + Spec: keystonev1.KeystoneApplicationCredentialSpec{ + UserName: watcher.ServiceName, + Secret: servicePasswordSecret, + PasswordSelector: passwordSelector, + Roles: []string{"admin", "member"}, + AccessRules: []keystonev1.ACRule{{Service: "identity", Method: "POST", Path: "/auth/tokens"}}, + ExpirationDays: 30, + GracePeriodDays: 5, + }, + } + DeferCleanup(k8sClient.Delete, ctx, ac) + Expect(k8sClient.Create(ctx, ac)).To(Succeed()) + + // Simulate AC controller updating the status + fetched := &keystonev1.KeystoneApplicationCredential{} + key := types.NamespacedName{Namespace: ac.Namespace, Name: ac.Name} + Expect(k8sClient.Get(ctx, key, fetched)).To(Succeed()) + + fetched.Status.SecretName = acSecretName + now := metav1.Now() + readyCond := condition.Condition{ + Type: condition.ReadyCondition, + Status: corev1.ConditionTrue, + Reason: condition.ReadyReason, + Message: condition.ReadyMessage, + LastTransitionTime: now, + } + fetched.Status.Conditions = condition.Conditions{readyCond} + Expect(k8sClient.Status().Update(ctx, fetched)).To(Succeed()) + + spec := GetDefaultWatcherDecisionEngineSpec() + spec["auth"] = map[string]any{ + "applicationCredentialSecret": acSecretName, + } + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.WatcherAPI, spec)) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherAPIStatefulSet) + + DeferCleanup(th.DeleteInstance, CreateWatcherDecisionEngine(watcherTest.WatcherDecisionEngine, spec)) + th.SimulateStatefulSetReplicaReady(watcherTest.WatcherDecisionEngineStatefulSet) + }) + + It("should render ApplicationCredential auth in 00-default.conf", func() { + // Wait for the config to be generated and updated with AC auth + Eventually(func(g Gomega) { + cfgSecret := th.GetSecret(watcherTest.WatcherDecisionEngineConfigSecret) + g.Expect(cfgSecret).NotTo(BeNil()) + + conf := string(cfgSecret.Data["00-default.conf"]) + + // AC auth is configured + g.Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + g.Expect(conf).To(ContainSubstring("application_credential_id = test-ac-id")) + g.Expect(conf).To(ContainSubstring("application_credential_secret = test-ac-secret")) + + // 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()) + }) + }) })