diff --git a/Makefile b/Makefile index fabf4f7..21ab30e 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,7 @@ setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \ $(KIND) create cluster --name $(KIND_CLUSTER) ;; \ esac + $(KIND) export kubeconfig --name $(KIND_CLUSTER) .PHONY: test-e2e test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Use RUN_ONLY= to focus specs. diff --git a/README.md b/README.md index e2bb21c..487dfdf 100644 --- a/README.md +++ b/README.md @@ -206,32 +206,29 @@ Redis ACL rules are configured through `spec.aclRules`. The operator always owns # Controller configuration All of these settings are optional, and have safe defaults. If you don't need to change any of these settings, you can skip this section. -Only 1 controller resource is allowed, if there are more than 1, the operator will log an error and ignore all but the first one it finds. - - ## Cross-namespace existing secrets (optional) By default, the `existingSecret` value is resolved in the same namespace as the `PostgresAccess` resource. -To allow cross-namespace references, create exactly one `Controller` resource as found in `config/samples/access_v1_controller.yaml` in the same namespace as the operator. +To allow cross-namespace references, create `ConfigMap/access-operator-settings` as shown in `config/samples/access_v1_controller.yaml` in the same namespace as the operator. Excluded usernames are skipped during normal reconciliation and orphan cleanup, so the operator will not create, update, or delete those roles. ## Ignoring users (optional) By default, no users are ignored **except** for Postgres users that can't login (rolcanlogin == false) or are superusers (rolsuper == true). -To exclude certain usernames from being managed by the operator, you can specify them in the `Controller` resource as well. +To exclude certain usernames from being managed by the operator, you can specify them in the operator settings ConfigMap as well. This is useful for excluding default users like `postgres` or `admin` that are created by the service itself for example. -Add the service's key (postgres, rabbitmq, redis) within the settings as shown here in the CR to exclude the `postgres`, `admin`, and `default` users: +Add the service's key (postgres, rabbitmq, redis) within the embedded config document to exclude the `postgres`, `admin`, and `default` users: ```yaml -spec: - settings: - existingSecretNamespace: false - postgres: - excludedUsers: - - postgres - rabbitmq: - excludedUsers: - - admin - redis: - excludedUsers: - - default +existingSecretNamespace: false +postgres: + excludedUsers: + - postgres +rabbitmq: + excludedUsers: + - admin +redis: + excludedUsers: + - default ``` + +You can also control stale-user cleanup per backend from the same singleton `Controller` resource. The safe default is `Restrict`, which retains users that are no longer referenced by any managed access resource. For PostgreSQL, the controller-scoped policy uses `Cascade`, `Restrict`, `Orphan`, or `Retain`. `Retain` disables stale-user cleanup during steady-state reconciliation but still allows the specific `PostgresAccess` being deleted to finalize its own role. Redis and RabbitMQ use `Delete` or `Restrict`. diff --git a/api/v1/controller_deepcopy.go b/api/v1/controller_deepcopy.go index f428345..b861d19 100644 --- a/api/v1/controller_deepcopy.go +++ b/api/v1/controller_deepcopy.go @@ -16,74 +16,11 @@ limitations under the License. package v1 -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" -) - -// DeepCopyInto copies all properties of this object into another object of the same type. -func (in *Controller) DeepCopyInto(out *Controller) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy returns a deep copy of this object. -func (in *Controller) DeepCopy() *Controller { - if in == nil { - return nil - } - out := new(Controller) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject returns a generically typed copy of this object. -func (in *Controller) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto copies all properties of this object into another object of the same type. -func (in *ControllerList) DeepCopyInto(out *ControllerList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]Controller, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy returns a deep copy of this object. -func (in *ControllerList) DeepCopy() *ControllerList { - if in == nil { - return nil - } - out := new(ControllerList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject returns a generically typed copy of this object. -func (in *ControllerList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto copies all properties of this object into another object of the same type. func (in *ControllerSettings) DeepCopyInto(out *ControllerSettings) { *out = *in in.PostgresSettings.DeepCopyInto(&out.PostgresSettings) + in.RabbitMQSettings.DeepCopyInto(&out.RabbitMQSettings) + in.RedisSettings.DeepCopyInto(&out.RedisSettings) } // DeepCopy returns a deep copy of this object. @@ -96,12 +33,6 @@ func (in *ControllerSettings) DeepCopy() *ControllerSettings { return out } -// DeepCopyInto copies all properties of this object into another object of the same type. -func (in *ControllerSpec) DeepCopyInto(out *ControllerSpec) { - *out = *in - in.Settings.DeepCopyInto(&out.Settings) -} - // DeepCopyInto copies all properties of this object into another object of the same type. func (in *PostgresControllerSettings) DeepCopyInto(out *PostgresControllerSettings) { *out = *in @@ -110,6 +41,11 @@ func (in *PostgresControllerSettings) DeepCopyInto(out *PostgresControllerSettin *out = make([]string, len(*in)) copy(*out, *in) } + if in.StaleUserDeletionPolicy != nil { + in, out := &in.StaleUserDeletionPolicy, &out.StaleUserDeletionPolicy + *out = new(PostgresCleanupPolicy) + **out = **in + } } // DeepCopy returns a deep copy of this object. @@ -121,35 +57,3 @@ func (in *PostgresControllerSettings) DeepCopy() *PostgresControllerSettings { in.DeepCopyInto(out) return out } - -// DeepCopy returns a deep copy of this object. -func (in *ControllerSpec) DeepCopy() *ControllerSpec { - if in == nil { - return nil - } - out := new(ControllerSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto copies all properties of this object into another object of the same type. -func (in *ControllerStatus) DeepCopyInto(out *ControllerStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]metav1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy returns a deep copy of this object. -func (in *ControllerStatus) DeepCopy() *ControllerStatus { - if in == nil { - return nil - } - out := new(ControllerStatus) - in.DeepCopyInto(out) - return out -} diff --git a/api/v1/controller_types.go b/api/v1/controller_types.go index ab1b254..88fe851 100644 --- a/api/v1/controller_types.go +++ b/api/v1/controller_types.go @@ -14,11 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -// +kubebuilder:object:generate=true package v1 -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +// StaleUserDeletionPolicy defines how the controller handles managed users +// that are no longer referenced by any managed access resource. +// +kubebuilder:validation:Enum=Delete;Restrict +type StaleUserDeletionPolicy string + +const ( + // StaleUserDeletionPolicyDelete removes unreferenced managed users. + StaleUserDeletionPolicyDelete StaleUserDeletionPolicy = "Delete" + // StaleUserDeletionPolicyRestrict retains unreferenced managed users. + StaleUserDeletionPolicyRestrict StaleUserDeletionPolicy = "Restrict" ) // StaleVhostDeletionPolicy defines how the controller handles RabbitMQ vhosts @@ -53,6 +60,13 @@ type RabbitMQControllerSettings struct { // +optional // +kubebuilder:default="Retain" StaleVhostDeletionPolicy *StaleVhostDeletionPolicy `json:"staleVhostDeletionPolicy,omitempty"` + + // staleUserDeletionPolicy controls whether the controller deletes RabbitMQ + // users that are no longer referenced by any managed RabbitMQAccess. + // Restrict retains stale users instead of deleting them. + // +optional + // +kubebuilder:default="Restrict" + StaleUserDeletionPolicy *StaleUserDeletionPolicy `json:"staleUserDeletionPolicy,omitempty"` } type PostgresControllerSettings struct { @@ -62,6 +76,14 @@ type PostgresControllerSettings struct { // +listType=set // +optional ExcludedUsers []string `json:"excludedUsers,omitempty"` + + // staleUserDeletionPolicy controls whether the controller deletes PostgreSQL + // roles that are no longer referenced by any managed PostgresAccess. + // Restrict retains stale roles, while Retain only permits deletion during + // finalization of the specific PostgresAccess being removed. + // +optional + // +kubebuilder:default="Restrict" + StaleUserDeletionPolicy *PostgresCleanupPolicy `json:"staleUserDeletionPolicy,omitempty"` } type RedisControllerSettings struct { @@ -71,6 +93,13 @@ type RedisControllerSettings struct { // +listType=set // +optional ExcludedUsers []string `json:"excludedUsers,omitempty"` + + // staleUserDeletionPolicy controls whether the controller deletes Redis ACL + // users that are no longer referenced by any managed RedisAccess. + // Restrict retains stale users instead of deleting them. + // +optional + // +kubebuilder:default="Restrict" + StaleUserDeletionPolicy *StaleUserDeletionPolicy `json:"staleUserDeletionPolicy,omitempty"` } // ControllerSettings defines operator-wide behavior toggles. @@ -93,55 +122,3 @@ type ControllerSettings struct { // +optional RedisSettings RedisControllerSettings `json:"redis,omitempty"` } - -// ControllerSpec defines the desired state of Controller. -type ControllerSpec struct { - // settings contains operator-wide settings. - // +optional - Settings ControllerSettings `json:"settings,omitempty"` -} - -// ControllerStatus defines the observed state of Controller. -type ControllerStatus struct { - // conditions represent the current state of this Controller resource. - // +listType=map - // +listMapKey=type - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -// +kubebuilder:resource:path=controllers,scope=Namespaced,singular=controller,shortName=actrl - -// Controller is the Schema for the controllers API. -type Controller struct { - metav1.TypeMeta `json:",inline"` - - // metadata is a standard object metadata. - // +optional - metav1.ObjectMeta `json:"metadata,omitzero"` - - // spec defines the desired state of Controller. - // +optional - Spec ControllerSpec `json:"spec,omitzero"` - - // status defines the observed state of Controller. - // +optional - Status ControllerStatus `json:"status,omitzero"` -} - -// +kubebuilder:object:root=true -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// ControllerList contains a list of Controller. -type ControllerList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitzero"` - Items []Controller `json:"items"` -} - -func init() { - SchemeBuilder.Register(&Controller{}, &ControllerList{}) -} diff --git a/api/v1/postgresaccess_types.go b/api/v1/postgresaccess_types.go index 1b7ff0c..aa64711 100644 --- a/api/v1/postgresaccess_types.go +++ b/api/v1/postgresaccess_types.go @@ -58,8 +58,8 @@ type ConnectionSpec struct { // existingSecretNamespace is the namespace where existingSecret is stored. // If omitted, defaults to the PostgresAccess namespace. - // Cross-namespace secret references are rejected unless a single Controller - // resource exists with spec.settings.existingSecretNamespace=true. + // Cross-namespace secret references are rejected unless the operator settings + // ConfigMap enables existingSecretNamespace=true. // +optional // +kubebuilder:validation:MinLength=1 ExistingSecretNamespace *string `json:"existingSecretNamespace,omitempty"` @@ -100,17 +100,20 @@ type ConnectionSpec struct { SSLMode *string `json:"sslMode,omitempty"` } -// CleanupPolicy specifies how to handle owned objects when dropping a user -// +kubebuilder:validation:Enum=Cascade;Restrict;Orphan -type CleanupPolicy string +// PostgresCleanupPolicy specifies how the controller handles PostgreSQL role deletion. +// +kubebuilder:validation:Enum=Cascade;Restrict;Orphan;Retain +type PostgresCleanupPolicy string const ( - // CleanupPolicyCascade drops all objects owned by the user (destructive) - CleanupPolicyCascade CleanupPolicy = "Cascade" - // CleanupPolicyRestrict prevents dropping the user if they own any objects - CleanupPolicyRestrict CleanupPolicy = "Restrict" - // CleanupPolicyOrphan reassigns owned objects to the current database owner before dropping - CleanupPolicyOrphan CleanupPolicy = "Orphan" + // CleanupPolicyCascade drops all objects owned by the user (destructive). + CleanupPolicyCascade PostgresCleanupPolicy = "Cascade" + // CleanupPolicyRestrict prevents dropping the user if they own any objects. + CleanupPolicyRestrict PostgresCleanupPolicy = "Restrict" + // CleanupPolicyOrphan reassigns owned objects to the current database owner before dropping. + CleanupPolicyOrphan PostgresCleanupPolicy = "Orphan" + // CleanupPolicyRetain retains stale roles during steady-state reconciliation and + // only allows deletion during finalization of the specific PostgresAccess. + CleanupPolicyRetain PostgresCleanupPolicy = "Retain" ) // GrantSpec defines database grants to be applied @@ -154,14 +157,6 @@ type PostgresAccessSpec struct { // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=1 Grants []GrantSpec `json:"grants"` - - // cleanupPolicy specifies how to handle owned objects when dropping a user - // Cascade: drops all objects owned by the user (destructive) - // Restrict: prevents dropping the user if they own any objects (safe default) - // Orphan: reassigns owned objects to the current database owner before dropping - // +optional - // +kubebuilder:default="Restrict" - CleanupPolicy *CleanupPolicy `json:"cleanupPolicy,omitempty"` } // ReconcileState defines the most recent reconcile outcome. diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 86e474e..7ce3b9a 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -23,7 +23,7 @@ package v1 import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -302,11 +302,6 @@ func (in *PostgresAccessSpec) DeepCopyInto(out *PostgresAccessSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.CleanupPolicy != nil { - in, out := &in.CleanupPolicy, &out.CleanupPolicy - *out = new(CleanupPolicy) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresAccessSpec. @@ -466,6 +461,11 @@ func (in *RabbitMQControllerSettings) DeepCopyInto(out *RabbitMQControllerSettin *out = new(StaleVhostDeletionPolicy) **out = **in } + if in.StaleUserDeletionPolicy != nil { + in, out := &in.StaleUserDeletionPolicy, &out.StaleUserDeletionPolicy + *out = new(StaleUserDeletionPolicy) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RabbitMQControllerSettings. @@ -603,6 +603,11 @@ func (in *RedisControllerSettings) DeepCopyInto(out *RedisControllerSettings) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.StaleUserDeletionPolicy != nil { + in, out := &in.StaleUserDeletionPolicy, &out.StaleUserDeletionPolicy + *out = new(StaleUserDeletionPolicy) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisControllerSettings. diff --git a/cmd/main.go b/cmd/main.go index d863357..dc288a9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,7 +22,7 @@ import ( "os" "github.com/delta10/access-operator/internal/controller/postgres" - "github.com/delta10/access-operator/internal/controller/rabbitMQ" + "github.com/delta10/access-operator/internal/controller/rabbitmq" rediscontroller "github.com/delta10/access-operator/internal/controller/redis" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) @@ -40,7 +40,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" accessv1 "github.com/delta10/access-operator/api/v1" - "github.com/delta10/access-operator/internal/controller" // +kubebuilder:scaffold:imports ) @@ -182,15 +181,6 @@ func main() { os.Exit(1) } - if err := (&controller.ControllerReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorder("controller-controller"), - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Controller") - os.Exit(1) - } - if err := (&postgres.PostgresAccessReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -199,7 +189,7 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "PostgresAccess") os.Exit(1) } - if err := (&rabbitMQ.AccessReconciler{ + if err := (&rabbitmq.AccessReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorder("rabbitmqaccess-controller"), diff --git a/config/crd/bases/access.k8s.delta10.nl_controllers.yaml b/config/crd/bases/access.k8s.delta10.nl_controllers.yaml deleted file mode 100644 index 249099b..0000000 --- a/config/crd/bases/access.k8s.delta10.nl_controllers.yaml +++ /dev/null @@ -1,183 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.20.0 - name: controllers.access.k8s.delta10.nl -spec: - group: access.k8s.delta10.nl - names: - kind: Controller - listKind: ControllerList - plural: controllers - shortNames: - - actrl - singular: controller - scope: Namespaced - versions: - - name: v1 - schema: - openAPIV3Schema: - description: Controller is the Schema for the controllers API. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: spec defines the desired state of Controller. - properties: - settings: - description: settings contains operator-wide settings. - properties: - existingSecretNamespace: - default: false - description: |- - existingSecretNamespace enables cross-namespace references for - managed access resources that use spec.connection.existingSecretNamespace. - type: boolean - postgres: - description: postgres contains settings specific to PostgresAccess - controllers. - properties: - excludedUsers: - description: |- - excludedUsers is a list of PostgreSQL usernames that the controller ignores - when reconciling PostgresAccess resources. - This prevents the operator from creating, updating, or deleting the listed roles. - items: - type: string - type: array - x-kubernetes-list-type: set - type: object - rabbitmq: - description: rabbitmq contains settings specific to RabbitMQAccess - controllers. - properties: - excludedUsers: - description: |- - excludedUsers is a list of RabbitMQ usernames that the controller ignores - when reconciling RabbitMQAccess resources. - This prevents the operator from creating, updating, or deleting the listed users. - items: - type: string - type: array - x-kubernetes-list-type: set - excludedVhosts: - description: |- - excludedVhosts is a list of RabbitMQ vhosts that the controller ignores - when reconciling stale RabbitMQ vhosts. - This prevents the operator from deleting the listed vhosts. - items: - type: string - type: array - x-kubernetes-list-type: set - staleVhostDeletionPolicy: - default: Retain - description: |- - staleVhostDeletionPolicy controls whether the controller deletes RabbitMQ - vhosts that are no longer referenced by any managed RabbitMQAccess. - enum: - - Delete - - Retain - type: string - type: object - redis: - description: redis contains settings specific to RedisAccess controllers. - properties: - excludedUsers: - description: |- - excludedUsers is a list of Redis ACL usernames that the controller ignores - when reconciling RedisAccess resources. - This prevents the operator from creating, updating, or deleting the listed users. - items: - type: string - type: array - x-kubernetes-list-type: set - type: object - type: object - type: object - status: - description: status defines the observed state of Controller. - properties: - conditions: - description: conditions represent the current state of this Controller - resource. - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/config/crd/bases/access.k8s.delta10.nl_postgresaccesses.yaml b/config/crd/bases/access.k8s.delta10.nl_postgresaccesses.yaml index 8688771..7ee0879 100644 --- a/config/crd/bases/access.k8s.delta10.nl_postgresaccesses.yaml +++ b/config/crd/bases/access.k8s.delta10.nl_postgresaccesses.yaml @@ -46,18 +46,6 @@ spec: spec: description: spec defines the desired state of PostgresAccess properties: - cleanupPolicy: - default: Restrict - description: |- - cleanupPolicy specifies how to handle owned objects when dropping a user - Cascade: drops all objects owned by the user (destructive) - Restrict: prevents dropping the user if they own any objects (safe default) - Orphan: reassigns owned objects to the current database owner before dropping - enum: - - Cascade - - Restrict - - Orphan - type: string connection: description: |- connection defines how to connect to PostgreSQL @@ -79,8 +67,8 @@ spec: description: |- existingSecretNamespace is the namespace where existingSecret is stored. If omitted, defaults to the PostgresAccess namespace. - Cross-namespace secret references are rejected unless a single Controller - resource exists with spec.settings.existingSecretNamespace=true. + Cross-namespace secret references are rejected unless the operator settings + ConfigMap enables existingSecretNamespace=true. minLength: 1 type: string host: diff --git a/config/crd/bases/access.k8s.delta10.nl_rabbitmqaccesses.yaml b/config/crd/bases/access.k8s.delta10.nl_rabbitmqaccesses.yaml index 289a462..7b6c59c 100644 --- a/config/crd/bases/access.k8s.delta10.nl_rabbitmqaccesses.yaml +++ b/config/crd/bases/access.k8s.delta10.nl_rabbitmqaccesses.yaml @@ -59,8 +59,8 @@ spec: description: |- existingSecretNamespace is the namespace where existingSecret is stored. If omitted, defaults to the PostgresAccess namespace. - Cross-namespace secret references are rejected unless a single Controller - resource exists with spec.settings.existingSecretNamespace=true. + Cross-namespace secret references are rejected unless the operator settings + ConfigMap enables existingSecretNamespace=true. minLength: 1 type: string host: diff --git a/config/crd/bases/access.k8s.delta10.nl_redisaccesses.yaml b/config/crd/bases/access.k8s.delta10.nl_redisaccesses.yaml index 09c9123..f8c1608 100644 --- a/config/crd/bases/access.k8s.delta10.nl_redisaccesses.yaml +++ b/config/crd/bases/access.k8s.delta10.nl_redisaccesses.yaml @@ -78,8 +78,8 @@ spec: description: |- existingSecretNamespace is the namespace where existingSecret is stored. If omitted, defaults to the PostgresAccess namespace. - Cross-namespace secret references are rejected unless a single Controller - resource exists with spec.settings.existingSecretNamespace=true. + Cross-namespace secret references are rejected unless the operator settings + ConfigMap enables existingSecretNamespace=true. minLength: 1 type: string host: diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 7fa147f..50ff752 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,7 +2,6 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: -- bases/access.k8s.delta10.nl_controllers.yaml - bases/access.k8s.delta10.nl_postgresaccesses.yaml - bases/access.k8s.delta10.nl_rabbitmqaccesses.yaml - bases/access.k8s.delta10.nl_redisaccesses.yaml diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index d0b59e6..c3104a0 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -65,6 +65,11 @@ spec: - --health-probe-bind-address=:8081 image: controller:latest name: manager + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace ports: [] securityContext: readOnlyRootFilesystem: true diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2c74ecb..e65c070 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -7,7 +7,7 @@ rules: - apiGroups: - "" resources: - - pods + - configmaps verbs: - get - list @@ -35,7 +35,6 @@ rules: - apiGroups: - access.k8s.delta10.nl resources: - - controllers - cronjobs - postgresaccesses - rabbitmqaccesses @@ -51,7 +50,6 @@ rules: - apiGroups: - access.k8s.delta10.nl resources: - - controllers/finalizers - cronjobs/finalizers - postgresaccesses/finalizers - rabbitmqaccesses/finalizers @@ -61,7 +59,6 @@ rules: - apiGroups: - access.k8s.delta10.nl resources: - - controllers/status - cronjobs/status - postgresaccesses/status - rabbitmqaccesses/status @@ -70,14 +67,3 @@ rules: - get - patch - update -- apiGroups: - - apps - resources: - - deployments - verbs: - - create - - get - - list - - patch - - update - - watch diff --git a/config/samples/access_v1_controller.yaml b/config/samples/access_v1_controller.yaml index 4c6a0fb..8d593c6 100644 --- a/config/samples/access_v1_controller.yaml +++ b/config/samples/access_v1_controller.yaml @@ -1,19 +1,33 @@ -apiVersion: access.k8s.delta10.nl/v1 -kind: Controller +apiVersion: v1 +kind: ConfigMap metadata: labels: app.kubernetes.io/name: access-operator app.kubernetes.io/managed-by: kustomize - name: controller-sample + name: access-operator-settings namespace: access-operator-system -spec: - settings: +data: + settings.yaml: | # Default is false. # Set to true to allow PostgresAccess connection.existingSecretNamespace # to reference a Secret in a different namespace. - # This Controller resource must live in the operator namespace. existingSecretNamespace: false # Configure PostgreSQL-specific controller behavior. postgres: - excludedUsers: - - postgres + excludedUsers: + - postgres + # Default is Restrict. + # Controls whether stale PostgreSQL roles are deleted or retained. + # Retain disables stale-user cleanup outside of finalization for the + # specific PostgresAccess being deleted. + staleUserDeletionPolicy: Restrict + # Configure RabbitMQ-specific controller behavior. + rabbitmq: + # Default is Restrict. + staleUserDeletionPolicy: Restrict + # Default is Retain. + staleVhostDeletionPolicy: Retain + # Configure Redis-specific controller behavior. + redis: + # Default is Restrict. + staleUserDeletionPolicy: Restrict diff --git a/config/samples/access_v1_postgresaccess_conn_secret.yaml b/config/samples/access_v1_postgresaccess_conn_secret.yaml index fccb106..247e080 100644 --- a/config/samples/access_v1_postgresaccess_conn_secret.yaml +++ b/config/samples/access_v1_postgresaccess_conn_secret.yaml @@ -12,8 +12,8 @@ spec: connection: existingSecret: postgres-admin-secret # optional: read from another namespace. - # requires a singleton Controller CR with: - # spec.settings.existingSecretNamespace=true + # requires ConfigMap/access-operator-settings with: + # data.settings.yaml -> existingSecretNamespace=true # existingSecretNamespace: platform-databases grants: - database: postgres diff --git a/internal/controller/README.md b/internal/controller/README.md index 620d23a..8461340 100644 --- a/internal/controller/README.md +++ b/internal/controller/README.md @@ -116,7 +116,7 @@ func (r *MyServiceAccessReconciler) SetupWithManager(mgr ctrl.Manager) error { } ``` -Use a custom reconcile loop instead of `ReconcileManagedAccess` when the controller is not access-oriented, does not manage generated credentials, or needs different status or watch behavior. The `ControllerReconciler` in [`controller_controller.go`](./controller_controller.go) is the example for that shape. +Use a custom reconcile loop instead of `ReconcileManagedAccess` when the controller is not access-oriented, does not manage generated credentials, or needs different status or watch behavior. ## 6. Implement reconciliation with repository conventions diff --git a/internal/controller/controller_controller.go b/internal/controller/controller_controller.go deleted file mode 100644 index 733bfe1..0000000 --- a/internal/controller/controller_controller.go +++ /dev/null @@ -1,317 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "errors" - "fmt" - "os" - "sort" - "strconv" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/handler" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - accessv1 "github.com/delta10/access-operator/api/v1" -) - -const ( - controllerReadyConditionType = "Ready" - - MultipleControllersFoundReason = "MultipleControllersFound" - invalidControllerNamespace = "InvalidControllerNamespace" - deploymentReconcileFailed = "DeploymentReconcileFailed" - - managerControlPlaneLabelKey = "control-plane" - managerControlPlaneLabelValue = "controller-manager" - managerAppNameLabelKey = "app.kubernetes.io/name" - managerAppNameLabelValue = "access-operator" - - managerPolicyAnnotationKey = "access.k8s.delta10.nl/existing-secret-namespace" - - defaultManagerDeploymentName = "controller-manager" - defaultManagerDeploymentNamespace = "system" -) - -// ControllerReconciler reconciles a Controller object. -type ControllerReconciler struct { - client.Client - Scheme *runtime.Scheme - Recorder events.EventRecorder -} - -// +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=controllers,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=controllers/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=controllers/finalizers,verbs=update -// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch -// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch -// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch - -// Reconcile enforces singleton Controller behavior across the cluster. -func (r *ControllerReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { - log := logf.FromContext(ctx) - - var controllers accessv1.ControllerList - if err := r.List(ctx, &controllers); err != nil { - return ctrl.Result{}, err - } - - switch len(controllers.Items) { - case 0: - log.Info("No Controller resources found; using safe defaults") - return ctrl.Result{}, nil - case 1: - controllerObj := controllers.Items[0] - managerKey, err := r.resolveManagerDeploymentKey(ctx) - if err != nil { - _ = r.setControllerReadyCondition( - ctx, - types.NamespacedName{Name: controllerObj.Name, Namespace: controllerObj.Namespace}, - metav1.ConditionFalse, - deploymentReconcileFailed, - err.Error(), - ) - return ctrl.Result{}, err - } - if controllerObj.Namespace != managerKey.Namespace { - message := fmt.Sprintf( - "Controller resource %s/%s must be created in the operator namespace %q", - controllerObj.Namespace, - controllerObj.Name, - managerKey.Namespace, - ) - _ = r.setControllerReadyCondition( - ctx, - types.NamespacedName{Name: controllerObj.Name, Namespace: controllerObj.Namespace}, - metav1.ConditionFalse, - invalidControllerNamespace, - message, - ) - r.emitWarningEvent(&controllerObj, invalidControllerNamespace, message) - return ctrl.Result{}, errors.New(message) - } - if err := r.reconcileManagerDeployment(ctx, managerKey, &controllerObj); err != nil { - _ = r.setControllerReadyCondition( - ctx, - types.NamespacedName{Name: controllerObj.Name, Namespace: controllerObj.Namespace}, - metav1.ConditionFalse, - deploymentReconcileFailed, - err.Error(), - ) - return ctrl.Result{}, err - } - if err := r.setControllerReadyCondition( - ctx, - types.NamespacedName{Name: controllerObj.Name, Namespace: controllerObj.Namespace}, - metav1.ConditionTrue, - "Ready", - "Controller singleton configuration is valid", - ); err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - default: - message := fmt.Sprintf( - "multiple Controller resources found (%d); exactly one is allowed cluster-wide", - len(controllers.Items), - ) - for _, controllerObj := range controllers.Items { - key := types.NamespacedName{Name: controllerObj.Name, Namespace: controllerObj.Namespace} - _ = r.setControllerReadyCondition(ctx, key, metav1.ConditionFalse, MultipleControllersFoundReason, message) - r.emitWarningEvent(&controllerObj, MultipleControllersFoundReason, message) - } - r.emitWarningOnManagerDeployments(ctx, MultipleControllersFoundReason, message) - - return ctrl.Result{}, errors.New(message) - } -} - -func (r *ControllerReconciler) reconcileManagerDeployment( - ctx context.Context, - key types.NamespacedName, - controllerObj *accessv1.Controller, -) error { - deployment := &appsv1.Deployment{} - if err := r.Get(ctx, key, deployment); err != nil { - if apierrors.IsNotFound(err) { - return fmt.Errorf( - "manager deployment %s/%s not found; deploy operator manifests before configuring Controller singleton policy", - key.Namespace, - key.Name, - ) - } - return err - } - - _, err := controllerutil.CreateOrUpdate(ctx, r.Client, deployment, func() error { - if deployment.Annotations == nil { - deployment.Annotations = map[string]string{} - } - deployment.Annotations[managerPolicyAnnotationKey] = strconv.FormatBool( - controllerObj.Spec.Settings.ExistingSecretNamespace, - ) - - return nil - }) - - return err -} - -func (r *ControllerReconciler) resolveManagerDeploymentKey(ctx context.Context) (types.NamespacedName, error) { - managerDeployments, err := ListManagerDeployments(ctx, r.Client) - if err != nil { - return types.NamespacedName{}, err - } - - switch len(managerDeployments) { - case 0: - podNamespace := os.Getenv("POD_NAMESPACE") - if podNamespace != "" { - return types.NamespacedName{Name: defaultManagerDeploymentName, Namespace: podNamespace}, nil - } - return types.NamespacedName{ - Name: defaultManagerDeploymentName, - Namespace: defaultManagerDeploymentNamespace, - }, nil - case 1: - return types.NamespacedName{ - Name: managerDeployments[0].Name, - Namespace: managerDeployments[0].Namespace, - }, nil - default: - podNamespace := os.Getenv("POD_NAMESPACE") - if podNamespace != "" { - for _, deployment := range managerDeployments { - if deployment.Namespace == podNamespace { - return types.NamespacedName{Name: deployment.Name, Namespace: deployment.Namespace}, nil - } - } - } - - sort.Slice(managerDeployments, func(i, j int) bool { - if managerDeployments[i].Namespace == managerDeployments[j].Namespace { - return managerDeployments[i].Name < managerDeployments[j].Name - } - return managerDeployments[i].Namespace < managerDeployments[j].Namespace - }) - - return types.NamespacedName{ - Name: managerDeployments[0].Name, - Namespace: managerDeployments[0].Namespace, - }, nil - } -} - -func (r *ControllerReconciler) emitWarningOnManagerDeployments(ctx context.Context, reason, message string) { - deployments, err := ListManagerDeployments(ctx, r.Client) - if err != nil { - return - } - - for _, deployment := range deployments { - deploymentCopy := deployment - r.emitWarningEvent( - &deploymentCopy, - reason, - fmt.Sprintf("%s (controller-manager deployment: %s/%s)", message, deployment.Namespace, deployment.Name), - ) - } -} - -func (r *ControllerReconciler) emitWarningEvent(object client.Object, reason, message string) { - if r.Recorder == nil || object == nil { - return - } - - r.Recorder.Eventf(object, nil, corev1.EventTypeWarning, reason, "PolicyValidation", "%s", message) -} - -func (r *ControllerReconciler) setControllerReadyCondition( - ctx context.Context, - key types.NamespacedName, - status metav1.ConditionStatus, - reason, message string, -) error { - var latest accessv1.Controller - if err := r.Get(ctx, key, &latest); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return err - } - - meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: controllerReadyConditionType, - Status: status, - Reason: reason, - Message: message, - ObservedGeneration: latest.GetGeneration(), - }) - - return r.Status().Update(ctx, &latest) -} - -func (r *ControllerReconciler) mapManagerPodToControllers(ctx context.Context, _ client.Object) []reconcile.Request { - var controllers accessv1.ControllerList - if err := r.List(ctx, &controllers); err != nil { - return nil - } - - requests := make([]reconcile.Request, 0, len(controllers.Items)) - for _, controllerObj := range controllers.Items { - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: controllerObj.Name, - Namespace: controllerObj.Namespace, - }, - }) - } - - return requests -} - -// SetupWithManager sets up the controller with the Manager. -func (r *ControllerReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&accessv1.Controller{}). - Owns(&appsv1.Deployment{}). - Watches( - &corev1.Pod{}, - handler.EnqueueRequestsFromMapFunc(r.mapManagerPodToControllers), - builder.WithPredicates(predicate.NewPredicateFuncs(func(object client.Object) bool { - return object.GetLabels()[managerControlPlaneLabelKey] == managerControlPlaneLabelValue - })), - ). - Named("controller"). - Complete(r) -} diff --git a/internal/controller/controller_controller_test.go b/internal/controller/controller_controller_test.go deleted file mode 100644 index a70bd26..0000000 --- a/internal/controller/controller_controller_test.go +++ /dev/null @@ -1,209 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - accessv1 "github.com/delta10/access-operator/api/v1" -) - -var _ = Describe("Controller Controller", func() { - ctx := context.Background() - - It("should fail reconciliation, mark all Controller resources not ready, and emit warning events when multiples exist", func() { - controllerAKey := types.NamespacedName{Name: "controller-a", Namespace: "system"} - controllerBKey := types.NamespacedName{Name: "controller-b", Namespace: "default"} - - fakeClient, fakeScheme := NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: controllerAKey.Name, Namespace: controllerAKey.Namespace}, - }, - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: controllerBKey.Name, Namespace: controllerBKey.Namespace}, - }, - &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "access-operator-controller-manager", - Namespace: "access-operator-system", - Labels: map[string]string{ - managerControlPlaneLabelKey: managerControlPlaneLabelValue, - managerAppNameLabelKey: managerAppNameLabelValue, - }, - }, - }, - ) - - eventRecorder := events.NewFakeRecorder(20) - reconciler := &ControllerReconciler{ - Client: fakeClient, - Scheme: fakeScheme, - Recorder: eventRecorder, - } - - _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: controllerAKey}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("multiple Controller resources found")) - - for _, key := range []types.NamespacedName{controllerAKey, controllerBKey} { - controllerObj := &accessv1.Controller{} - Expect(fakeClient.Get(ctx, key, controllerObj)).To(Succeed()) - - readyCondition := meta.FindStatusCondition(controllerObj.Status.Conditions, controllerReadyConditionType) - Expect(readyCondition).NotTo(BeNil()) - Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse)) - Expect(readyCondition.Reason).To(Equal(MultipleControllersFoundReason)) - } - - allEvents := ReceiveEvents(eventRecorder.Events, 3) - Expect(allEvents).To(ContainSubstring(MultipleControllersFoundReason)) - Expect(allEvents).To(ContainSubstring("controller-manager deployment: access-operator-system/access-operator-controller-manager")) - }) - - It("should reconcile the manager deployment idempotently for the singleton Controller", func() { - controllerKey := types.NamespacedName{Name: "cluster-settings", Namespace: "system"} - deploymentKey := client.ObjectKey{ - Name: defaultManagerDeploymentName, - Namespace: defaultManagerDeploymentNamespace, - } - fakeClient, fakeScheme := NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: controllerKey.Name, Namespace: controllerKey.Namespace}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - ExistingSecretNamespace: true, - }, - }, - }, - &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentKey.Name, - Namespace: deploymentKey.Namespace, - Labels: map[string]string{ - managerControlPlaneLabelKey: managerControlPlaneLabelValue, - managerAppNameLabelKey: managerAppNameLabelValue, - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "manager", - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "manager"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "manager", - Image: "example.com/access-operator:v0.0.1", - }}, - }, - }, - }, - }, - ) - - reconciler := &ControllerReconciler{ - Client: fakeClient, - Scheme: fakeScheme, - } - - _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: controllerKey}) - Expect(err).NotTo(HaveOccurred()) - - firstDeployment := &appsv1.Deployment{} - Expect(fakeClient.Get(ctx, deploymentKey, firstDeployment)).To(Succeed()) - Expect(firstDeployment.Annotations).To(HaveKeyWithValue(managerPolicyAnnotationKey, "true")) - firstCopy := firstDeployment.DeepCopy() - - _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: controllerKey}) - Expect(err).NotTo(HaveOccurred()) - - secondDeployment := &appsv1.Deployment{} - Expect(fakeClient.Get(ctx, deploymentKey, secondDeployment)).To(Succeed()) - Expect(secondDeployment.Annotations).To(HaveKeyWithValue(managerPolicyAnnotationKey, "true")) - Expect(secondDeployment.Spec).To(Equal(firstCopy.Spec)) - Expect(secondDeployment.Annotations).To(Equal(firstCopy.Annotations)) - - var deploymentList appsv1.DeploymentList - Expect(fakeClient.List(ctx, &deploymentList)).To(Succeed()) - Expect(deploymentList.Items).To(HaveLen(1)) - }) - - It("should reject a singleton Controller outside the operator namespace", func() { - controllerKey := types.NamespacedName{Name: "cluster-settings", Namespace: "default"} - deploymentKey := client.ObjectKey{ - Name: "access-operator-controller-manager", - Namespace: "access-operator-system", - } - fakeClient, fakeScheme := NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: controllerKey.Name, Namespace: controllerKey.Namespace}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - ExistingSecretNamespace: true, - }, - }, - }, - &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentKey.Name, - Namespace: deploymentKey.Namespace, - Labels: map[string]string{ - managerControlPlaneLabelKey: managerControlPlaneLabelValue, - managerAppNameLabelKey: managerAppNameLabelValue, - }, - }, - }, - ) - - eventRecorder := events.NewFakeRecorder(10) - reconciler := &ControllerReconciler{ - Client: fakeClient, - Scheme: fakeScheme, - Recorder: eventRecorder, - } - - _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: controllerKey}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(`must be created in the operator namespace "access-operator-system"`)) - - controllerObj := &accessv1.Controller{} - Expect(fakeClient.Get(ctx, controllerKey, controllerObj)).To(Succeed()) - - readyCondition := meta.FindStatusCondition(controllerObj.Status.Conditions, controllerReadyConditionType) - Expect(readyCondition).NotTo(BeNil()) - Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse)) - Expect(readyCondition.Reason).To(Equal(invalidControllerNamespace)) - - event := ReceiveEvents(eventRecorder.Events, 1) - Expect(event).To(ContainSubstring(invalidControllerNamespace)) - }) -}) diff --git a/internal/controller/internal_shared_logic.go b/internal/controller/internal_shared.go similarity index 74% rename from internal/controller/internal_shared_logic.go rename to internal/controller/internal_shared.go index db66dd2..4ced34a 100644 --- a/internal/controller/internal_shared_logic.go +++ b/internal/controller/internal_shared.go @@ -2,7 +2,6 @@ package controller import ( "context" - "errors" "fmt" "net" neturl "net/url" @@ -16,6 +15,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +const serviceAccountNamespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + func resolveValueOrSecretRef( ctx context.Context, c client.Client, @@ -48,69 +49,27 @@ func resolveValueOrSecretRef( func resolveExistingSecretNamespacePolicy( ctx context.Context, c client.Client, - onMultiple SharedControllerMultipleHandler, ) (bool, error) { - controllerObj, err := resolveSingletonController(ctx, c, onMultiple) + settings, err := ResolveControllerSettings(ctx, c) if err != nil { return false, err } - if controllerObj == nil { - return false, nil - } - - if !controllerObj.Spec.Settings.ExistingSecretNamespace { + if !settings.ExistingSecretNamespace { return false, nil } - operatorNamespace, err := resolveOperatorNamespace(ctx, c) - if err != nil { - return false, err - } - if controllerObj.Namespace != operatorNamespace { - return false, fmt.Errorf( - "cross-namespace connection secret references are disabled: Controller resource %q must be created in the operator namespace %q, found in %q", - controllerObj.Name, - operatorNamespace, - controllerObj.Namespace, - ) - } - return true, nil } -func resolveSingletonController( - ctx context.Context, - c client.Client, - onMultiple SharedControllerMultipleHandler, -) (*accessv1.Controller, error) { - var controllers accessv1.ControllerList - if err := c.List(ctx, &controllers); err != nil { - return nil, err - } - - switch len(controllers.Items) { - case 0: - return nil, nil - case 1: - return &controllers.Items[0], nil - default: - message := fmt.Sprintf( - "multiple Controller resources found (%d); exactly one is allowed cluster-wide", - len(controllers.Items), - ) - if onMultiple != nil { - for i := range controllers.Items { - onMultiple(&controllers.Items[i], message) - } - } - return nil, errors.New(message) - } -} - func resolveOperatorNamespace(ctx context.Context, c client.Client) (string, error) { if podNamespace := strings.TrimSpace(os.Getenv("POD_NAMESPACE")); podNamespace != "" { return podNamespace, nil } + if data, err := os.ReadFile(serviceAccountNamespaceFile); err == nil { + if namespace := strings.TrimSpace(string(data)); namespace != "" { + return namespace, nil + } + } managerDeployments, err := ListManagerDeployments(ctx, c) if err != nil { diff --git a/internal/controller/postgres/db.go b/internal/controller/postgres/db.go index 1b3c63d..429a011 100644 --- a/internal/controller/postgres/db.go +++ b/internal/controller/postgres/db.go @@ -32,7 +32,7 @@ type DBInterface interface { Close(ctx context.Context) error CreateUser(ctx context.Context, username, password string) error UpdateUserPassword(ctx context.Context, username, newPassword string) error - DropUser(ctx context.Context, username string, cleanupPolicy accessv1.CleanupPolicy) error + DropUser(ctx context.Context, username string, cleanupPolicy accessv1.PostgresCleanupPolicy) error GetUsers(ctx context.Context) ([]string, error) GrantPrivileges(ctx context.Context, grants []accessv1.GrantSpec, username string) error RevokePrivileges(ctx context.Context, grants []accessv1.GrantSpec, username string) error @@ -101,7 +101,7 @@ func (p *DB) UpdateUserPassword(ctx context.Context, username, newPassword strin return err } -func (p *DB) DropUser(ctx context.Context, username string, policy accessv1.CleanupPolicy) (err error) { +func (p *DB) DropUser(ctx context.Context, username string, policy accessv1.PostgresCleanupPolicy) (err error) { if p.conn == nil { return fmt.Errorf("database connection is not initialized") } @@ -206,6 +206,9 @@ func (p *DB) DropUser(ctx context.Context, username string, policy accessv1.Clea return fmt.Errorf("drop owned (privileges): %w", err) } + case accessv1.CleanupPolicyRetain: + return fmt.Errorf("cannot drop user %q with cleanup policy Retain", username) + default: return fmt.Errorf("unknown cleanup policy: %s", policy) } diff --git a/internal/controller/postgres/mock_db.go b/internal/controller/postgres/mock_db.go index b0cbcd9..718edce 100644 --- a/internal/controller/postgres/mock_db.go +++ b/internal/controller/postgres/mock_db.go @@ -16,6 +16,7 @@ type MockDB struct { LastUsername string LastCreatedUsername string LastDroppedUsername string + LastDropCleanupPolicy accessv1.PostgresCleanupPolicy CreatedUsernames []string DroppedUsernames []string LastPassword string @@ -56,9 +57,10 @@ func (m *MockDB) UpdateUserPassword(ctx context.Context, username, newPassword s return nil } -func (m *MockDB) DropUser(ctx context.Context, username string, cleanupPolicy accessv1.CleanupPolicy) error { +func (m *MockDB) DropUser(ctx context.Context, username string, cleanupPolicy accessv1.PostgresCleanupPolicy) error { m.DropUserCalled = true m.LastDroppedUsername = username + m.LastDropCleanupPolicy = cleanupPolicy m.DroppedUsernames = append(m.DroppedUsernames, username) return nil } diff --git a/internal/controller/postgres/postgresaccess_connection.go b/internal/controller/postgres/postgresaccess_connection.go index 203414e..a09ca18 100644 --- a/internal/controller/postgres/postgresaccess_connection.go +++ b/internal/controller/postgres/postgresaccess_connection.go @@ -3,7 +3,6 @@ package postgres import ( "context" "fmt" - "strings" accessv1 "github.com/delta10/access-operator/api/v1" "github.com/delta10/access-operator/internal/controller" @@ -61,14 +60,8 @@ func (r *PostgresAccessReconciler) resolveExistingSecretNamespace(ctx context.Co r.Client, pg.Namespace, pg.Spec.Connection.ExistingSecretNamespace, - func(controllerObj *accessv1.Controller, message string) { - r.emitEvent(controllerObj, "Warning", controller.MultipleControllersFoundReason, message) - }, ) if err != nil { - if strings.Contains(err.Error(), "multiple Controller resources found") { - r.emitEvent(pg, "Warning", controller.MultipleControllersFoundReason, err.Error()) - } return "", err } @@ -84,6 +77,23 @@ func (r *PostgresAccessReconciler) resolveExcludedUsers(ctx context.Context) (ma return controller.NormalizeExcludedUsers(settings.PostgresSettings.ExcludedUsers), nil } +func (r *PostgresAccessReconciler) resolveStaleUserDeletionPolicy(ctx context.Context) (accessv1.PostgresCleanupPolicy, error) { + if r.Client == nil { + return accessv1.CleanupPolicyRestrict, nil + } + + settings, err := resolvePostgresControllerSettings(ctx, r) + if err != nil { + return "", err + } + + if settings.PostgresSettings.StaleUserDeletionPolicy == nil { + return accessv1.CleanupPolicyRestrict, nil + } + + return *settings.PostgresSettings.StaleUserDeletionPolicy, nil +} + func formatConnectionString(connection controller.ConnectionDetails) string { return fmt.Sprintf( "postgresql://%s:%s@%s:%s/%s?sslmode=%s", diff --git a/internal/controller/postgres/postgresaccess_controller.go b/internal/controller/postgres/postgresaccess_controller.go index 1242fe6..058ed87 100644 --- a/internal/controller/postgres/postgresaccess_controller.go +++ b/internal/controller/postgres/postgresaccess_controller.go @@ -27,12 +27,16 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" accessv1 "github.com/delta10/access-operator/api/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) // PostgresAccessReconciler reconciles a PostgresAccess object @@ -63,10 +67,10 @@ func postgresReconcileStatusConfig() controller.ReconcileStatusConfig[*accessv1. } // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=postgresaccesses,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=postgresaccesses/status,verbs=get;update;patch // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=postgresaccesses/finalizers,verbs=update -// +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=controllers,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch @@ -152,6 +156,10 @@ func (r *PostgresAccessReconciler) reconcilePostgresAccess(ctx context.Context, if err != nil { return false, err } + staleUserDeletionPolicy, err := r.resolveStaleUserDeletionPolicy(ctx) + if err != nil { + return false, err + } usersHandled := make(map[string]bool) for username := range excludedUsers { @@ -215,14 +223,15 @@ func (r *PostgresAccessReconciler) reconcilePostgresAccess(ctx context.Context, } // remove users that are not handled by any PostgresAccess CR anymore - // Use Restrict policy as the safe default for orphaned users - for _, user := range users { - if !usersHandled[user] { - err = r.DB.DropUser(ctx, user, accessv1.CleanupPolicyRestrict) - if err != nil { - log.Error(err, "failed to drop user in PostgreSQL", "username", user) - inSync = false - continue + if shouldDeleteStalePostgresUsers(staleUserDeletionPolicy) { + for _, user := range users { + if !usersHandled[user] { + err = r.DB.DropUser(ctx, user, staleUserDeletionPolicy) + if err != nil { + log.Error(err, "failed to drop user in PostgreSQL", "username", user) + inSync = false + continue + } } } } @@ -282,20 +291,23 @@ func (r *PostgresAccessReconciler) finalizePostgresAccess(ctx context.Context, p return true, err } - // Determine cleanup policy, default to Restrict if not specified - cleanupPolicy := accessv1.CleanupPolicyRestrict - if pg.Spec.CleanupPolicy != nil { - cleanupPolicy = *pg.Spec.CleanupPolicy + staleUserDeletionPolicy, err := r.resolveStaleUserDeletionPolicy(ctx) + if err != nil { + return true, err } - for _, user := range users { - if pg.Spec.Username == user { - err = r.DB.DropUser(ctx, user, cleanupPolicy) - if err != nil { - log.Error(err, "failed to drop user in PostgreSQL during finalization", "username", user) - continue + if finalizationPolicy, shouldDelete := postgresFinalizationCleanupPolicy(staleUserDeletionPolicy); shouldDelete { + for _, user := range users { + if pg.Spec.Username == user { + err = r.DB.DropUser(ctx, user, finalizationPolicy) + if err != nil { + log.Error(err, "failed to drop user in PostgreSQL during finalization", "username", user) + continue + } } } + } else { + log.Info("Skipping finalizer PostgreSQL user deletion because stale user deletion policy disables it", "username", pg.Spec.Username) } if err := controller.RemoveAccessFinalizerIfPresent(ctx, r.Client, pg); err != nil { @@ -311,9 +323,51 @@ func (r *PostgresAccessReconciler) SetupWithManager(mgr ctrl.Manager) error { For(&accessv1.PostgresAccess{}). Named("postgresaccess"). Owns(&corev1.Secret{}). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + if !controller.IsControllerSettingsConfigMap(obj) { + return nil + } + + var accesses accessv1.PostgresAccessList + if err := r.List(ctx, &accesses); err != nil { + return nil + } + + requests := make([]reconcile.Request, 0, len(accesses.Items)) + for i := range accesses.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&accesses.Items[i]), + }) + } + + return requests + }), + builder.WithPredicates(predicate.NewPredicateFuncs(controller.IsControllerSettingsConfigMap)), + ). Complete(r) } +func shouldDeleteStalePostgresUsers(policy accessv1.PostgresCleanupPolicy) bool { + return policy != accessv1.CleanupPolicyRestrict && policy != accessv1.CleanupPolicyRetain +} + +func postgresFinalizationCleanupPolicy( + policy accessv1.PostgresCleanupPolicy, +) (accessv1.PostgresCleanupPolicy, bool) { + switch policy { + case accessv1.CleanupPolicyCascade, accessv1.CleanupPolicyOrphan: + return policy, true + case accessv1.CleanupPolicyRetain: + // Retain disables background cleanup but still allows deleting the + // specific managed role during finalization using safe Restrict semantics. + return accessv1.CleanupPolicyRestrict, true + default: + return "", false + } +} + func (r *PostgresAccessReconciler) emitEvent(object client.Object, eventType, reason, message string) { controller.EmitEvent(r.Recorder, object, eventType, reason, message) } @@ -368,7 +422,5 @@ func getUserPassword(ctx context.Context, c client.Client, namespace, secretName } func resolvePostgresControllerSettings(ctx context.Context, r *PostgresAccessReconciler) (accessv1.ControllerSettings, error) { - return controller.ResolveControllerSettings(ctx, r.Client, func(controllerObj *accessv1.Controller, message string) { - r.emitEvent(controllerObj, "Warning", controller.MultipleControllersFoundReason, message) - }) + return controller.ResolveControllerSettings(ctx, r.Client) } diff --git a/internal/controller/postgres/postgresaccess_controller_test.go b/internal/controller/postgres/postgresaccess_controller_test.go index da84971..5e3bbef 100644 --- a/internal/controller/postgres/postgresaccess_controller_test.go +++ b/internal/controller/postgres/postgresaccess_controller_test.go @@ -24,6 +24,7 @@ import ( "time" "github.com/delta10/access-operator/internal/controller" + "github.com/delta10/access-operator/test" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -262,7 +263,7 @@ var _ = Describe("PostgresAccess Controller", func() { }) It("should default ssl mode to require when missing in existing secret", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -308,7 +309,7 @@ var _ = Describe("PostgresAccess Controller", func() { It("should build connection strings from an existing secret", func() { expectedString := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", username, password, host, port, database) - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -339,8 +340,8 @@ var _ = Describe("PostgresAccess Controller", func() { Expect(connectionString).To(Equal(expectedString)) }) - It("should reject cross-namespace existingSecret when no Controller resource exists", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( + It("should reject cross-namespace existingSecret when no settings ConfigMap exists", func() { + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -373,19 +374,11 @@ var _ = Describe("PostgresAccess Controller", func() { Expect(err.Error()).To(ContainSubstring("cross-namespace connection secret references are disabled")) }) - It("should reject cross-namespace existingSecret when singleton Controller policy is false", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "system", - }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - ExistingSecretNamespace: false, - }, - }, - }, + It("should reject cross-namespace existingSecret when settings ConfigMap policy is false", func() { + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + ExistingSecretNamespace: false, + }), &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -418,19 +411,11 @@ var _ = Describe("PostgresAccess Controller", func() { Expect(err.Error()).To(ContainSubstring("cross-namespace connection secret references are disabled")) }) - It("should allow cross-namespace existingSecret when singleton Controller policy is true", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "system", - }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - ExistingSecretNamespace: true, - }, - }, - }, + It("should allow cross-namespace existingSecret when settings ConfigMap policy is true", func() { + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + ExistingSecretNamespace: true, + }), &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -463,19 +448,11 @@ var _ = Describe("PostgresAccess Controller", func() { Expect(connectionString).To(Equal("postgresql://db-admin:secret@postgres.shared-db.svc:5432/appdb?sslmode=require")) }) - It("should reject cross-namespace existingSecret when singleton Controller is outside the operator namespace", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "tenant-a", - }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - ExistingSecretNamespace: true, - }, - }, - }, + It("should ignore settings ConfigMap outside operator namespace", func() { + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("tenant-a", accessv1.ControllerSettings{ + ExistingSecretNamespace: true, + }), &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -505,24 +482,16 @@ var _ = Describe("PostgresAccess Controller", func() { _, err := reconciler.getConnectionString(context.Background(), pg) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(`must be created in the operator namespace "system"`)) + Expect(err.Error()).To(ContainSubstring("cross-namespace connection secret references are disabled")) }) - It("should normalize excluded usernames from singleton Controller settings", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "system", + It("should normalize excluded usernames from settings ConfigMap", func() { + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + PostgresSettings: accessv1.PostgresControllerSettings{ + ExcludedUsers: []string{" postgres ", "", "app-user", "postgres"}, }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - PostgresSettings: accessv1.PostgresControllerSettings{ - ExcludedUsers: []string{" postgres ", "", "app-user", "postgres"}, - }, - }, - }, - }, + }), ) reconciler := &PostgresAccessReconciler{Client: fakeClient} @@ -533,59 +502,36 @@ var _ = Describe("PostgresAccess Controller", func() { Expect(excludedUsers).To(HaveKey("app-user")) }) - It("should hard fail cross-namespace existingSecret when multiple Controller resources exist", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "controller-a", Namespace: "system"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, - }, - }, - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "controller-b", Namespace: "default"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, - }, - }, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: "shared-db", - }, - Data: map[string][]byte{ - "host": []byte("postgres"), - "port": []byte(strconv.Itoa(int(port))), - "database": []byte(database), - "username": []byte(username), - "password": []byte(password), + It("should default stale user deletion policy to Restrict", func() { + reconciler := &PostgresAccessReconciler{} + policy, err := reconciler.resolveStaleUserDeletionPolicy(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(policy).To(Equal(accessv1.CleanupPolicyRestrict)) + }) + + It("should resolve stale user deletion policy from settings ConfigMap", func() { + orphanPolicy := accessv1.CleanupPolicyOrphan + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + PostgresSettings: accessv1.PostgresControllerSettings{ + StaleUserDeletionPolicy: &orphanPolicy, }, - }, + }), ) - eventRecorder := events.NewFakeRecorder(10) - reconciler := &PostgresAccessReconciler{ - Client: fakeClient, - Recorder: eventRecorder, - } - secretNamespace := "shared-db" - pg := &accessv1.PostgresAccess{ - ObjectMeta: metav1.ObjectMeta{Name: "tenant-access", Namespace: "tenant-a"}, - Spec: accessv1.PostgresAccessSpec{ - Connection: accessv1.ConnectionSpec{ - ExistingSecret: &secretName, - ExistingSecretNamespace: &secretNamespace, - }, - }, - } + reconciler := &PostgresAccessReconciler{Client: fakeClient} + policy, err := reconciler.resolveStaleUserDeletionPolicy(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(policy).To(Equal(accessv1.CleanupPolicyOrphan)) + }) - _, err := reconciler.getConnectionString(context.Background(), pg) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("multiple Controller resources found")) + It("should treat Retain as no stale-user deletion during reconciliation and Restrict during finalization", func() { + Expect(shouldDeleteStalePostgresUsers(accessv1.CleanupPolicyRetain)).To(BeFalse()) - allEvents := controller.ReceiveEvents(eventRecorder.Events, 3) - Expect(allEvents).To(ContainSubstring(controller.MultipleControllersFoundReason)) + finalizationPolicy, shouldDelete := postgresFinalizationCleanupPolicy(accessv1.CleanupPolicyRetain) + Expect(shouldDelete).To(BeTrue()) + Expect(finalizationPolicy).To(Equal(accessv1.CleanupPolicyRestrict)) }) - It("should return an error when no valid connection details are provided", func() { reconciler := &PostgresAccessReconciler{} pg := &accessv1.PostgresAccess{ @@ -611,7 +557,7 @@ var _ = Describe("PostgresAccess Controller", func() { } delete(data, missingKey) - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -633,7 +579,7 @@ var _ = Describe("PostgresAccess Controller", func() { It("should fall back to postgres database when database name is missing or invalid in existing secret", func() { expectedString := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", username, password, host, port, "postgres") - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -723,7 +669,7 @@ var _ = Describe("PostgresAccess Controller", func() { secondUser := "app-2" otherNamespaceUser := "other" - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &accessv1.PostgresAccess{ ObjectMeta: metav1.ObjectMeta{Name: "first", Namespace: "target"}, Spec: accessv1.PostgresAccessSpec{ @@ -773,7 +719,7 @@ var _ = Describe("PostgresAccess Controller", func() { }) It("should read user password from the generated secret name", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "custom-generated-secret", @@ -797,7 +743,7 @@ var _ = Describe("PostgresAccess Controller", func() { Namespace: "default", }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(pg) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg) reconciler := &PostgresAccessReconciler{ Client: fakeClient, @@ -846,7 +792,7 @@ var _ = Describe("PostgresAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(pg) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg) eventRecorder := events.NewFakeRecorder(5) reconciler := &PostgresAccessReconciler{ Client: fakeClient, @@ -880,7 +826,7 @@ var _ = Describe("PostgresAccess Controller", func() { Expect(updated.Status.LastReconcileState).To(Equal(accessv1.ReconcileStateError)) Expect(updated.Status.LastLog).To(ContainSubstring("no valid connection details provided")) - event := controller.ReceiveEvents(eventRecorder.Events, 1) + event := test.ReceiveEvents(eventRecorder.Events, 1) Expect(event).To(ContainSubstring("DatabaseSyncFailed")) }) @@ -907,7 +853,7 @@ var _ = Describe("PostgresAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(pg) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg) reconciler := &PostgresAccessReconciler{ Client: fakeClient, Scheme: fakeScheme, @@ -993,7 +939,7 @@ var _ = Describe("PostgresAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme( + fakeClient, fakeScheme := test.NewFakeClientWithScheme( pg, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -1059,21 +1005,13 @@ var _ = Describe("PostgresAccess Controller", func() { }, } - controllerSettings := &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "system", - }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - PostgresSettings: accessv1.PostgresControllerSettings{ - ExcludedUsers: []string{username, "excluded-orphan"}, - }, - }, + controllerSettings := test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + PostgresSettings: accessv1.PostgresControllerSettings{ + ExcludedUsers: []string{username, "excluded-orphan"}, }, - } + }) - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(pg, controllerSettings) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg, controllerSettings) mockDB := NewMockDB() mockDB.Users = []string{"excluded-orphan"} reconciler := &PostgresAccessReconciler{ @@ -1102,7 +1040,7 @@ var _ = Describe("PostgresAccess Controller", func() { deletingPG.Finalizers = []string{postgresAccessFinalizer} deletingPG.DeletionTimestamp = &now - finalizerClient, finalizerScheme := controller.NewFakeClientWithScheme(deletingPG, controllerSettings.DeepCopy()) + finalizerClient, finalizerScheme := test.NewFakeClientWithScheme(deletingPG, controllerSettings.DeepCopy()) finalizerReconciler := &PostgresAccessReconciler{ Client: finalizerClient, Scheme: finalizerScheme, @@ -1118,6 +1056,273 @@ var _ = Describe("PostgresAccess Controller", func() { err = finalizerClient.Get(context.Background(), client.ObjectKeyFromObject(pg), finalizedResource) Expect(apierrors.IsNotFound(err)).To(BeTrue()) }) + + It("should retain stale PostgreSQL users during reconciliation when stale user deletion policy is Restrict", func() { + const managedUsername = "managed-user" + host := localHost + port := int32(5432) + username := managedUsername + + pg := &accessv1.PostgresAccess{ + ObjectMeta: metav1.ObjectMeta{Name: "retain-stale-user", Namespace: "default"}, + Spec: accessv1.PostgresAccessSpec{ + GeneratedSecret: "retain-stale-user-secret", + Username: managedUsername, + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Database: &database, + Username: &accessv1.SecretKeySelector{Value: &username}, + Password: &accessv1.SecretKeySelector{Value: &password}, + }, + Grants: []accessv1.GrantSpec{ + {Database: database, Privileges: []string{"CONNECT", "SELECT"}}, + }, + }, + } + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg) + mockDB := NewMockDB() + mockDB.Users = []string{managedUsername, "stale-user"} + reconciler := &PostgresAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + DB: mockDB, + } + + _, err := reconciler.reconcilePostgresAccess(context.Background(), pg) + Expect(err).NotTo(HaveOccurred()) + Expect(mockDB.DroppedUsernames).To(BeEmpty()) + }) + + It("should drop stale PostgreSQL users during reconciliation when stale user deletion policy is Orphan", func() { + const managedUsername = "managed-user" + host := localHost + port := int32(5432) + username := managedUsername + orphanPolicy := accessv1.CleanupPolicyOrphan + + pg := &accessv1.PostgresAccess{ + ObjectMeta: metav1.ObjectMeta{Name: "delete-stale-user", Namespace: "default"}, + Spec: accessv1.PostgresAccessSpec{ + GeneratedSecret: "delete-stale-user-secret", + Username: managedUsername, + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Database: &database, + Username: &accessv1.SecretKeySelector{Value: &username}, + Password: &accessv1.SecretKeySelector{Value: &password}, + }, + Grants: []accessv1.GrantSpec{ + {Database: database, Privileges: []string{"CONNECT", "SELECT"}}, + }, + }, + } + controllerSettings := test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + PostgresSettings: accessv1.PostgresControllerSettings{ + StaleUserDeletionPolicy: &orphanPolicy, + }, + }) + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg, controllerSettings) + mockDB := NewMockDB() + mockDB.Users = []string{managedUsername, "stale-user"} + reconciler := &PostgresAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + DB: mockDB, + } + + _, err := reconciler.reconcilePostgresAccess(context.Background(), pg) + Expect(err).NotTo(HaveOccurred()) + Expect(mockDB.DroppedUsernames).To(ContainElement("stale-user")) + Expect(mockDB.LastDropCleanupPolicy).To(Equal(accessv1.CleanupPolicyOrphan)) + }) + + It("should retain stale PostgreSQL users during reconciliation when stale user deletion policy is Retain", func() { + const managedUsername = "managed-user" + host := localHost + port := int32(5432) + username := managedUsername + retainPolicy := accessv1.CleanupPolicyRetain + + pg := &accessv1.PostgresAccess{ + ObjectMeta: metav1.ObjectMeta{Name: "retain-stale-user-retain", Namespace: "default"}, + Spec: accessv1.PostgresAccessSpec{ + GeneratedSecret: "retain-stale-user-retain-secret", + Username: managedUsername, + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Database: &database, + Username: &accessv1.SecretKeySelector{Value: &username}, + Password: &accessv1.SecretKeySelector{Value: &password}, + }, + Grants: []accessv1.GrantSpec{ + {Database: database, Privileges: []string{"CONNECT", "SELECT"}}, + }, + }, + } + controllerSettings := test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + PostgresSettings: accessv1.PostgresControllerSettings{ + StaleUserDeletionPolicy: &retainPolicy, + }, + }) + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg, controllerSettings) + mockDB := NewMockDB() + mockDB.Users = []string{managedUsername, "stale-user"} + reconciler := &PostgresAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + DB: mockDB, + } + + _, err := reconciler.reconcilePostgresAccess(context.Background(), pg) + Expect(err).NotTo(HaveOccurred()) + Expect(mockDB.DroppedUsernames).To(BeEmpty()) + }) + + It("should retain PostgreSQL users during finalization when stale user deletion policy is Restrict", func() { + const managedUsername = "managed-user" + now := metav1.NewTime(time.Now()) + host := localHost + port := int32(5432) + username := managedUsername + + pg := &accessv1.PostgresAccess{ + ObjectMeta: metav1.ObjectMeta{ + Name: "retain-user-finalizer", + Namespace: "default", + Finalizers: []string{postgresAccessFinalizer}, + DeletionTimestamp: &now, + }, + Spec: accessv1.PostgresAccessSpec{ + GeneratedSecret: "retain-user-finalizer-secret", + Username: username, + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Database: &database, + Username: &accessv1.SecretKeySelector{Value: &username}, + Password: &accessv1.SecretKeySelector{Value: &password}, + }, + }, + } + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg) + mockDB := NewMockDB() + mockDB.Users = []string{managedUsername} + reconciler := &PostgresAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + DB: mockDB, + } + + finalized, err := reconciler.finalizePostgresAccess(context.Background(), pg) + Expect(err).NotTo(HaveOccurred()) + Expect(finalized).To(BeTrue()) + Expect(mockDB.DropUserCalled).To(BeFalse()) + }) + + It("should drop PostgreSQL users during finalization when stale user deletion policy is Cascade", func() { + now := metav1.NewTime(time.Now()) + host := localHost + port := int32(5432) + username := "managed-user" + cascadePolicy := accessv1.CleanupPolicyCascade + + pg := &accessv1.PostgresAccess{ + ObjectMeta: metav1.ObjectMeta{ + Name: "drop-user-finalizer", + Namespace: "default", + Finalizers: []string{postgresAccessFinalizer}, + DeletionTimestamp: &now, + }, + Spec: accessv1.PostgresAccessSpec{ + GeneratedSecret: "drop-user-finalizer-secret", + Username: username, + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Database: &database, + Username: &accessv1.SecretKeySelector{Value: &username}, + Password: &accessv1.SecretKeySelector{Value: &password}, + }, + }, + } + controllerSettings := test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + PostgresSettings: accessv1.PostgresControllerSettings{ + StaleUserDeletionPolicy: &cascadePolicy, + }, + }) + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg, controllerSettings) + mockDB := NewMockDB() + mockDB.Users = []string{username} + reconciler := &PostgresAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + DB: mockDB, + } + + finalized, err := reconciler.finalizePostgresAccess(context.Background(), pg) + Expect(err).NotTo(HaveOccurred()) + Expect(finalized).To(BeTrue()) + Expect(mockDB.DropUserCalled).To(BeTrue()) + Expect(mockDB.LastDroppedUsername).To(Equal(username)) + Expect(mockDB.LastDropCleanupPolicy).To(Equal(accessv1.CleanupPolicyCascade)) + }) + + It("should drop PostgreSQL users during finalization when stale user deletion policy is Retain", func() { + now := metav1.NewTime(time.Now()) + host := localHost + port := int32(5432) + username := "managed-user" + retainPolicy := accessv1.CleanupPolicyRetain + + pg := &accessv1.PostgresAccess{ + ObjectMeta: metav1.ObjectMeta{ + Name: "drop-user-finalizer-retain", + Namespace: "default", + Finalizers: []string{postgresAccessFinalizer}, + DeletionTimestamp: &now, + }, + Spec: accessv1.PostgresAccessSpec{ + GeneratedSecret: "drop-user-finalizer-retain-secret", + Username: username, + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Database: &database, + Username: &accessv1.SecretKeySelector{Value: &username}, + Password: &accessv1.SecretKeySelector{Value: &password}, + }, + }, + } + controllerSettings := test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + PostgresSettings: accessv1.PostgresControllerSettings{ + StaleUserDeletionPolicy: &retainPolicy, + }, + }) + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(pg, controllerSettings) + mockDB := NewMockDB() + mockDB.Users = []string{username} + reconciler := &PostgresAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + DB: mockDB, + } + + finalized, err := reconciler.finalizePostgresAccess(context.Background(), pg) + Expect(err).NotTo(HaveOccurred()) + Expect(finalized).To(BeTrue()) + Expect(mockDB.DropUserCalled).To(BeTrue()) + Expect(mockDB.LastDroppedUsername).To(Equal(username)) + Expect(mockDB.LastDropCleanupPolicy).To(Equal(accessv1.CleanupPolicyRestrict)) + }) }) }) diff --git a/internal/controller/rabbitMQ/rabbitmq.go b/internal/controller/rabbitmq/rabbitmq.go similarity index 99% rename from internal/controller/rabbitMQ/rabbitmq.go rename to internal/controller/rabbitmq/rabbitmq.go index f4904fd..ee35539 100644 --- a/internal/controller/rabbitMQ/rabbitmq.go +++ b/internal/controller/rabbitmq/rabbitmq.go @@ -1,4 +1,4 @@ -package rabbitMQ +package rabbitmq import ( accessv1 "github.com/delta10/access-operator/api/v1" diff --git a/internal/controller/rabbitMQ/rabbitmq_connection.go b/internal/controller/rabbitmq/rabbitmq_connection.go similarity index 88% rename from internal/controller/rabbitMQ/rabbitmq_connection.go rename to internal/controller/rabbitmq/rabbitmq_connection.go index 6c081bf..98e695f 100644 --- a/internal/controller/rabbitMQ/rabbitmq_connection.go +++ b/internal/controller/rabbitmq/rabbitmq_connection.go @@ -1,4 +1,4 @@ -package rabbitMQ +package rabbitmq import ( "context" @@ -10,7 +10,6 @@ import ( accessv1 "github.com/delta10/access-operator/api/v1" "github.com/delta10/access-operator/internal/controller" rabbithole "github.com/michaelklishin/rabbit-hole/v3" - corev1 "k8s.io/api/core/v1" ) const defaultRabbitMQAMQPPort = "5672" @@ -62,14 +61,8 @@ func (r *AccessReconciler) resolveExistingSecretNamespace( r.Client, rbq.Namespace, rbq.Spec.Connection.ExistingSecretNamespace, - func(controllerObj *accessv1.Controller, message string) { - controller.EmitEvent(r.Recorder, controllerObj, corev1.EventTypeWarning, controller.MultipleControllersFoundReason, message) - }, ) if err != nil { - if strings.Contains(err.Error(), "multiple Controller resources found") { - controller.EmitEvent(r.Recorder, rbq, corev1.EventTypeWarning, controller.MultipleControllersFoundReason, err.Error()) - } return "", err } @@ -132,3 +125,20 @@ func (r *AccessReconciler) resolveStaleVhostDeletionPolicy(ctx context.Context) return *settings.RabbitMQSettings.StaleVhostDeletionPolicy, nil } + +func (r *AccessReconciler) resolveStaleUserDeletionPolicy(ctx context.Context) (accessv1.StaleUserDeletionPolicy, error) { + if r.Client == nil { + return accessv1.StaleUserDeletionPolicyRestrict, nil + } + + settings, err := resolveRabbitMQControllerSettings(ctx, r) + if err != nil { + return "", err + } + + if settings.RabbitMQSettings.StaleUserDeletionPolicy == nil { + return accessv1.StaleUserDeletionPolicyRestrict, nil + } + + return *settings.RabbitMQSettings.StaleUserDeletionPolicy, nil +} diff --git a/internal/controller/rabbitMQ/rabbitmqaccess_controller.go b/internal/controller/rabbitmq/rabbitmqaccess_controller.go similarity index 86% rename from internal/controller/rabbitMQ/rabbitmqaccess_controller.go rename to internal/controller/rabbitmq/rabbitmqaccess_controller.go index 37af970..4377e9b 100644 --- a/internal/controller/rabbitMQ/rabbitmqaccess_controller.go +++ b/internal/controller/rabbitmq/rabbitmqaccess_controller.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package rabbitMQ +package rabbitmq import ( "context" @@ -29,9 +29,13 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" accessv1 "github.com/delta10/access-operator/api/v1" ) @@ -79,10 +83,10 @@ type AccessReconciler struct { } // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=rabbitmqaccesses,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=rabbitmqaccesses/status,verbs=get;update;patch // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=rabbitmqaccesses/finalizers,verbs=update -// +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=controllers,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -162,10 +166,16 @@ func (r *AccessReconciler) finalizeRabbitMQAccess(ctx context.Context, rbq *acce if err != nil { return true, fmt.Errorf("failed to list remaining RabbitMQAccess resources during finalization: %w", err) } + staleUserDeletionPolicy, err := r.resolveStaleUserDeletionPolicy(ctx) + if err != nil { + return true, fmt.Errorf("failed to resolve stale RabbitMQ user deletion policy during finalization: %w", err) + } if _, inUseByConnection := connectionUsers[rbq.Spec.Username]; !inUseByConnection { if _, stillDesired := remainingUsers[rbq.Spec.Username]; !stillDesired { - if _, exists := usersPermissions[rbq.Spec.Username]; exists { + if staleUserDeletionPolicy != accessv1.StaleUserDeletionPolicyDelete { + log.Info("Skipping finalizer RabbitMQ user deletion because stale user deletion policy is Restrict", "username", rbq.Spec.Username) + } else if _, exists := usersPermissions[rbq.Spec.Username]; exists { if err := r.DeleteUser(rmqc, rbq.Spec.Username); err != nil { return true, fmt.Errorf("failed to delete RabbitMQ user %s during finalization: %w", rbq.Spec.Username, err) } @@ -235,15 +245,19 @@ func reconcileRabbitMQ(ctx context.Context, r *AccessReconciler, rbq *accessv1.R excludedUsers, err := r.resolveExcludedUsers(ctx) if err != nil { - return false, controller.MultipleControllersFoundReason, fmt.Errorf("failed to resolve excluded users: %w", err) + return false, rabbitMQAccessListCRsErrorReason, fmt.Errorf("failed to resolve excluded users: %w", err) } excludedVhosts, err := r.resolveExcludedVhosts(ctx) if err != nil { - return false, controller.MultipleControllersFoundReason, fmt.Errorf("failed to resolve excluded vhosts: %w", err) + return false, rabbitMQAccessListCRsErrorReason, fmt.Errorf("failed to resolve excluded vhosts: %w", err) + } + staleUserDeletionPolicy, err := r.resolveStaleUserDeletionPolicy(ctx) + if err != nil { + return false, rabbitMQAccessListCRsErrorReason, fmt.Errorf("failed to resolve stale user deletion policy: %w", err) } staleVhostDeletionPolicy, err := r.resolveStaleVhostDeletionPolicy(ctx) if err != nil { - return false, controller.MultipleControllersFoundReason, fmt.Errorf("failed to resolve stale vhost deletion policy: %w", err) + return false, rabbitMQAccessListCRsErrorReason, fmt.Errorf("failed to resolve stale vhost deletion policy: %w", err) } usersAndVhostsInSync, reason, err := r.reconcileUsersAndVhosts(rmqc, desiredUsers, usersPermissions, excludedUsers, log) @@ -267,11 +281,13 @@ func reconcileRabbitMQ(ctx context.Context, r *AccessReconciler, rbq *accessv1.R } // delete users that are not referenced by any CR - for username := range unhandledUsers { - if err := r.DeleteUser(rmqc, username); err != nil { - return false, rabbitMQAccessDeleteErrorReason, fmt.Errorf("failed to delete user %s: %w", username, err) + if staleUserDeletionPolicy == accessv1.StaleUserDeletionPolicyDelete { + for username := range unhandledUsers { + if err := r.DeleteUser(rmqc, username); err != nil { + return false, rabbitMQAccessDeleteErrorReason, fmt.Errorf("failed to delete user %s: %w", username, err) + } + insync = false } - insync = false } currentVhosts, err := r.ListVhosts(rmqc) @@ -301,6 +317,29 @@ func (r *AccessReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&accessv1.RabbitMQAccess{}). Owns(&corev1.Secret{}). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + if !controller.IsControllerSettingsConfigMap(obj) { + return nil + } + + var accesses accessv1.RabbitMQAccessList + if err := r.List(ctx, &accesses); err != nil { + return nil + } + + requests := make([]reconcile.Request, 0, len(accesses.Items)) + for i := range accesses.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&accesses.Items[i]), + }) + } + + return requests + }), + builder.WithPredicates(predicate.NewPredicateFuncs(controller.IsControllerSettingsConfigMap)), + ). Named("rabbitmqaccess"). Complete(r) } @@ -451,9 +490,7 @@ func (r *AccessReconciler) reconcileUsersAndVhosts( } func resolveRabbitMQControllerSettings(ctx context.Context, r *AccessReconciler) (accessv1.ControllerSettings, error) { - return controller.ResolveControllerSettings(ctx, r.Client, func(controllerObj *accessv1.Controller, message string) { - controller.EmitEvent(r.Recorder, controllerObj, corev1.EventTypeWarning, controller.MultipleControllersFoundReason, message) - }) + return controller.ResolveControllerSettings(ctx, r.Client) } func permissionsEqual(desired, current []accessv1.RabbitMQPermissionSpec) bool { diff --git a/internal/controller/rabbitMQ/rabbitmqaccess_controller_test.go b/internal/controller/rabbitmq/rabbitmqaccess_controller_test.go similarity index 81% rename from internal/controller/rabbitMQ/rabbitmqaccess_controller_test.go rename to internal/controller/rabbitmq/rabbitmqaccess_controller_test.go index b3a6d94..0cbbf37 100644 --- a/internal/controller/rabbitMQ/rabbitmqaccess_controller_test.go +++ b/internal/controller/rabbitmq/rabbitmqaccess_controller_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package rabbitMQ +package rabbitmq import ( "context" @@ -22,6 +22,7 @@ import ( "time" "github.com/delta10/access-operator/internal/controller" + "github.com/delta10/access-operator/test" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -71,21 +72,13 @@ var _ = Describe("RabbitMQAccess Controller", func() { }) Context("When resolving excluded RabbitMQ users", func() { - It("should normalize excluded usernames from singleton Controller settings", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "access-operator-system", - }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - RabbitMQSettings: accessv1.RabbitMQControllerSettings{ - ExcludedUsers: []string{" admin ", "", "ops-user", "admin"}, - }, - }, + It("should normalize excluded usernames from settings ConfigMap", func() { + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RabbitMQSettings: accessv1.RabbitMQControllerSettings{ + ExcludedUsers: []string{" admin ", "", "ops-user", "admin"}, }, - }, + }), ) reconciler := &AccessReconciler{Client: fakeClient} @@ -95,24 +88,39 @@ var _ = Describe("RabbitMQAccess Controller", func() { Expect(excludedUsers).To(HaveKey("admin")) Expect(excludedUsers).To(HaveKey("ops-user")) }) + + It("should default stale user deletion policy to Restrict", func() { + reconciler := &AccessReconciler{} + policy, err := reconciler.resolveStaleUserDeletionPolicy(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(policy).To(Equal(accessv1.StaleUserDeletionPolicyRestrict)) + }) + + It("should resolve stale user deletion policy from settings ConfigMap", func() { + deletePolicy := accessv1.StaleUserDeletionPolicyDelete + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RabbitMQSettings: accessv1.RabbitMQControllerSettings{ + StaleUserDeletionPolicy: &deletePolicy, + }, + }), + ) + + reconciler := &AccessReconciler{Client: fakeClient} + policy, err := reconciler.resolveStaleUserDeletionPolicy(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(policy).To(Equal(accessv1.StaleUserDeletionPolicyDelete)) + }) }) Context("When resolving excluded RabbitMQ vhosts", func() { It("should normalize excluded vhosts and always retain the default vhost", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "access-operator-system", + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RabbitMQSettings: accessv1.RabbitMQControllerSettings{ + ExcludedVhosts: []string{" /shared ", "", "/team-a", "/shared"}, }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - RabbitMQSettings: accessv1.RabbitMQControllerSettings{ - ExcludedVhosts: []string{" /shared ", "", "/team-a", "/shared"}, - }, - }, - }, - }, + }), ) reconciler := &AccessReconciler{Client: fakeClient} @@ -207,7 +215,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { It("should build a management client from an existing secret", func() { secretName := testRabbitMQSecret - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -239,71 +247,14 @@ var _ = Describe("RabbitMQAccess Controller", func() { Expect(client.Password).To(Equal("secret")) }) - It("should hard fail cross-namespace existingSecret when multiple Controller resources exist", func() { - secretName := testRabbitMQSecret - secretNamespace := "shared-rabbitmq" - - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "controller-a", Namespace: "system"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, - }, - }, - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "controller-b", Namespace: "default"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, - }, - }, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: secretNamespace, - }, - Data: map[string][]byte{ - "host": []byte("rabbitmq"), - "port": []byte(strconv.Itoa(5672)), - "username": []byte("admin"), - "password": []byte("secret"), - }, - }, - ) - - eventRecorder := events.NewFakeRecorder(10) - reconciler := &AccessReconciler{ - Client: fakeClient, - Recorder: eventRecorder, - } - rbq := &accessv1.RabbitMQAccess{ - ObjectMeta: metav1.ObjectMeta{Name: "tenant-access", Namespace: "tenant-a"}, - Spec: accessv1.RabbitMQAccessSpec{ - Connection: accessv1.ConnectionSpec{ - ExistingSecret: &secretName, - ExistingSecretNamespace: &secretNamespace, - }, - }, - } - - _, err := reconciler.getConnectionDetails(context.Background(), rbq) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("multiple Controller resources found")) - - allEvents := controller.ReceiveEvents(eventRecorder.Events, 3) - Expect(allEvents).To(ContainSubstring(controller.MultipleControllersFoundReason)) - }) - - It("should reject cross-namespace existingSecret when singleton Controller is outside the operator namespace", func() { + It("should reject cross-namespace existingSecret when settings ConfigMap is outside the operator namespace", func() { secretName := testRabbitMQSecret secretNamespace := "shared-rabbitmq" - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "cluster-settings", Namespace: "tenant-a"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, - }, - }, + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("tenant-a", accessv1.ControllerSettings{ + ExistingSecretNamespace: true, + }), &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -331,7 +282,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { _, err := reconciler.getConnectionDetails(context.Background(), rbq) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(`must be created in the operator namespace "system"`)) + Expect(err.Error()).To(ContainSubstring("cross-namespace connection secret references are disabled")) }) }) @@ -342,7 +293,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { username := testRabbitMQUsername password := testRabbitMQPassword - fakeClient, fakeScheme := controller.NewFakeClientWithScheme( + fakeClient, fakeScheme := test.NewFakeClientWithScheme( &accessv1.RabbitMQAccess{ ObjectMeta: metav1.ObjectMeta{ Name: "rabbitmq-access", @@ -378,7 +329,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { password := testRabbitMQPassword now := metav1.NewTime(time.Now()) - fakeClient, fakeScheme := controller.NewFakeClientWithScheme( + fakeClient, fakeScheme := test.NewFakeClientWithScheme( &accessv1.RabbitMQAccess{ ObjectMeta: metav1.ObjectMeta{ Name: "active-rabbitmq-access", @@ -433,7 +384,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(rbq) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(rbq) reconciler := &AccessReconciler{ Client: fakeClient, Scheme: fakeScheme, @@ -480,21 +431,13 @@ var _ = Describe("RabbitMQAccess Controller", func() { }, } - controllerSettings := &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster-settings", - Namespace: "system", - }, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - RabbitMQSettings: accessv1.RabbitMQControllerSettings{ - ExcludedUsers: []string{"excluded-user"}, - }, - }, + controllerSettings := test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RabbitMQSettings: accessv1.RabbitMQControllerSettings{ + ExcludedUsers: []string{"excluded-user"}, }, - } + }) - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(rbq, controllerSettings) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(rbq, controllerSettings) reconciler := &AccessReconciler{ Client: fakeClient, Scheme: fakeScheme, @@ -525,7 +468,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(rbq) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(rbq) eventRecorder := events.NewFakeRecorder(5) reconciler := &AccessReconciler{ Client: fakeClient, @@ -558,7 +501,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { Expect(updated.Status.LastReconcileState).To(Equal(accessv1.ReconcileStateError)) Expect(updated.Status.LastLog).To(ContainSubstring("no valid connection details provided")) - event := controller.ReceiveEvents(eventRecorder.Events, 1) + event := test.ReceiveEvents(eventRecorder.Events, 1) Expect(event).To(ContainSubstring(rabbitMQAccessConnectionErrorReason)) }) @@ -577,7 +520,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(rbq) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(rbq) reconciler := &AccessReconciler{Client: fakeClient, Scheme: fakeScheme} configs, err := reconciler.getAllRabbitMQUserConfigs(context.Background()) @@ -621,7 +564,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(rbq, existingSecret) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(rbq, existingSecret) reconciler := &AccessReconciler{Client: fakeClient, Scheme: fakeScheme} configs, err := reconciler.getAllRabbitMQUserConfigs(context.Background()) @@ -669,7 +612,7 @@ var _ = Describe("RabbitMQAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(active, deleting) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(active, deleting) reconciler := &AccessReconciler{Client: fakeClient, Scheme: fakeScheme} configs, err := reconciler.getAllRabbitMQUserConfigs(context.Background()) diff --git a/internal/controller/rabbitMQ/suite_test.go b/internal/controller/rabbitmq/suite_test.go similarity index 99% rename from internal/controller/rabbitMQ/suite_test.go rename to internal/controller/rabbitmq/suite_test.go index 30f1868..d35416c 100644 --- a/internal/controller/rabbitMQ/suite_test.go +++ b/internal/controller/rabbitmq/suite_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package rabbitMQ +package rabbitmq import ( "context" diff --git a/internal/controller/redis/redis_connection.go b/internal/controller/redis/redis_connection.go index a5212d6..4284715 100644 --- a/internal/controller/redis/redis_connection.go +++ b/internal/controller/redis/redis_connection.go @@ -3,11 +3,9 @@ package redis import ( "context" "fmt" - "strings" accessv1 "github.com/delta10/access-operator/api/v1" "github.com/delta10/access-operator/internal/controller" - corev1 "k8s.io/api/core/v1" ) var connectionDefaults = accessv1.ConnectionSpec{} @@ -54,14 +52,8 @@ func (r *RedisAccessReconciler) resolveExistingSecretNamespace( r.Client, redisAccess.Namespace, redisAccess.Spec.Connection.ExistingSecretNamespace, - func(controllerObj *accessv1.Controller, message string) { - controller.EmitEvent(r.Recorder, controllerObj, corev1.EventTypeWarning, controller.MultipleControllersFoundReason, message) - }, ) if err != nil { - if strings.Contains(err.Error(), "multiple Controller resources found") { - controller.EmitEvent(r.Recorder, redisAccess, corev1.EventTypeWarning, controller.MultipleControllersFoundReason, err.Error()) - } return "", err } @@ -77,8 +69,23 @@ func (r *RedisAccessReconciler) resolveExcludedUsers(ctx context.Context) (map[s return controller.NormalizeExcludedUsers(settings.RedisSettings.ExcludedUsers), nil } +func (r *RedisAccessReconciler) resolveStaleUserDeletionPolicy(ctx context.Context) (accessv1.StaleUserDeletionPolicy, error) { + if r.Client == nil { + return accessv1.StaleUserDeletionPolicyRestrict, nil + } + + settings, err := resolveRedisControllerSettings(ctx, r) + if err != nil { + return "", err + } + + if settings.RedisSettings.StaleUserDeletionPolicy == nil { + return accessv1.StaleUserDeletionPolicyRestrict, nil + } + + return *settings.RedisSettings.StaleUserDeletionPolicy, nil +} + func resolveRedisControllerSettings(ctx context.Context, r *RedisAccessReconciler) (accessv1.ControllerSettings, error) { - return controller.ResolveControllerSettings(ctx, r.Client, func(controllerObj *accessv1.Controller, message string) { - controller.EmitEvent(r.Recorder, controllerObj, corev1.EventTypeWarning, controller.MultipleControllersFoundReason, message) - }) + return controller.ResolveControllerSettings(ctx, r.Client) } diff --git a/internal/controller/redis/redisaccess_controller.go b/internal/controller/redis/redisaccess_controller.go index eaddf5c..4baa20a 100644 --- a/internal/controller/redis/redisaccess_controller.go +++ b/internal/controller/redis/redisaccess_controller.go @@ -30,9 +30,13 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" accessv1 "github.com/delta10/access-operator/api/v1" ) @@ -83,10 +87,10 @@ func redisReconcileStatusConfig() controller.ReconcileStatusConfig[*accessv1.Red } // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=redisaccesses,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=redisaccesses/status,verbs=get;update;patch // +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=redisaccesses/finalizers,verbs=update -// +kubebuilder:rbac:groups=access.k8s.delta10.nl,resources=controllers,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch @@ -158,6 +162,14 @@ func (r *RedisAccessReconciler) finalizeRedisAccess(ctx context.Context, redisAc log.Info("Skipping finalizer Redis user deletion because another RedisAccess still manages it", "username", redisAccess.Spec.Username) return true, controller.RemoveAccessFinalizerIfPresent(ctx, r.Client, redisAccess) } + staleUserDeletionPolicy, err := r.resolveStaleUserDeletionPolicy(ctx) + if err != nil { + return true, fmt.Errorf("failed to resolve Redis stale user deletion policy during finalization: %w", err) + } + if staleUserDeletionPolicy != accessv1.StaleUserDeletionPolicyDelete { + log.Info("Skipping finalizer Redis user deletion because stale user deletion policy is Restrict", "username", redisAccess.Spec.Username) + return true, controller.RemoveAccessFinalizerIfPresent(ctx, r.Client, redisAccess) + } connection, err := r.getConnectionDetails(ctx, redisAccess) if err != nil { @@ -219,7 +231,11 @@ func (r *RedisAccessReconciler) reconcileRedisAccess( excludedUsers, err := r.resolveExcludedUsers(ctx) if err != nil { - return false, controller.MultipleControllersFoundReason, fmt.Errorf("failed to resolve excluded users: %w", err) + return false, redisAccessListCRsErrorReason, fmt.Errorf("failed to resolve excluded users: %w", err) + } + staleUserDeletionPolicy, err := r.resolveStaleUserDeletionPolicy(ctx) + if err != nil { + return false, redisAccessListCRsErrorReason, fmt.Errorf("failed to resolve stale user deletion policy: %w", err) } currentUsers, err := aclClient.ListUsers(ctx) @@ -261,25 +277,27 @@ func (r *RedisAccessReconciler) reconcileRedisAccess( } } - for _, username := range currentUsers { - if _, desired := desiredUsers[username]; desired { - continue - } - if _, excluded := excludedUsers[username]; excluded { - log.Info("Skipping excluded Redis user cleanup", "username", username) - continue - } - if _, protected := connectionUsers[username]; protected { - log.Info("Skipping Redis user cleanup because it is used for Redis connections", "username", username) - continue - } + if staleUserDeletionPolicy == accessv1.StaleUserDeletionPolicyDelete { + for _, username := range currentUsers { + if _, desired := desiredUsers[username]; desired { + continue + } + if _, excluded := excludedUsers[username]; excluded { + log.Info("Skipping excluded Redis user cleanup", "username", username) + continue + } + if _, protected := connectionUsers[username]; protected { + log.Info("Skipping Redis user cleanup because it is used for Redis connections", "username", username) + continue + } - deleted, err := aclClient.DeleteUser(ctx, username) - if err != nil { - return false, redisAccessDeleteErrorReason, fmt.Errorf("failed to delete Redis user %s: %w", username, err) - } - if deleted > 0 { - inSync = false + deleted, err := aclClient.DeleteUser(ctx, username) + if err != nil { + return false, redisAccessDeleteErrorReason, fmt.Errorf("failed to delete Redis user %s: %w", username, err) + } + if deleted > 0 { + inSync = false + } } } @@ -291,6 +309,29 @@ func (r *RedisAccessReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&accessv1.RedisAccess{}). Owns(&corev1.Secret{}). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + if !controller.IsControllerSettingsConfigMap(obj) { + return nil + } + + var accesses accessv1.RedisAccessList + if err := r.List(ctx, &accesses); err != nil { + return nil + } + + requests := make([]reconcile.Request, 0, len(accesses.Items)) + for i := range accesses.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&accesses.Items[i]), + }) + } + + return requests + }), + builder.WithPredicates(predicate.NewPredicateFuncs(controller.IsControllerSettingsConfigMap)), + ). Named("redisaccess"). Complete(r) } diff --git a/internal/controller/redis/redisaccess_controller_test.go b/internal/controller/redis/redisaccess_controller_test.go index 05bb807..cb01ba6 100644 --- a/internal/controller/redis/redisaccess_controller_test.go +++ b/internal/controller/redis/redisaccess_controller_test.go @@ -25,6 +25,7 @@ import ( "sync" "github.com/delta10/access-operator/internal/controller" + "github.com/delta10/access-operator/test" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -204,7 +205,7 @@ var _ = Describe("RedisAccess Controller", func() { It("should build Redis connection details from an existing secret", func() { secretName := testRedisSecretName - fakeClient, _ := controller.NewFakeClientWithScheme( + fakeClient, _ := test.NewFakeClientWithScheme( &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: "default"}, Data: map[string][]byte{ @@ -234,17 +235,14 @@ var _ = Describe("RedisAccess Controller", func() { Expect(connection.Password).To(Equal(testRedisAdminPassword)) }) - It("should allow cross-namespace existingSecret when singleton Controller policy is enabled", func() { + It("should allow cross-namespace existingSecret when settings ConfigMap policy is enabled", func() { secretName := testRedisSecretName secretNamespace := "shared-redis" - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "controller", Namespace: "system"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, - }, - }, + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + ExistingSecretNamespace: true, + }), &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: secretNamespace}, Data: map[string][]byte{ @@ -272,70 +270,44 @@ var _ = Describe("RedisAccess Controller", func() { Expect(connection.Host).To(Equal("redis.shared-redis.svc")) }) - It("should hard fail cross-namespace existingSecret when multiple Controller resources exist", func() { - secretName := "redis-connection-secret" - secretNamespace := "shared-redis" - - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "controller-a", Namespace: "system"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, + It("should normalize excluded usernames from settings ConfigMap", func() { + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RedisSettings: accessv1.RedisControllerSettings{ + ExcludedUsers: []string{" default ", "", "ops-user", "default"}, }, - }, - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "controller-b", Namespace: "default"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ExistingSecretNamespace: true}, - }, - }, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: secretNamespace}, - Data: map[string][]byte{ - "host": []byte("redis"), - "port": []byte("6379"), - "username": []byte("default"), - "password": []byte("secret"), - }, - }, + }), ) reconciler := &RedisAccessReconciler{Client: fakeClient} - redisAccess := &accessv1.RedisAccess{ - ObjectMeta: metav1.ObjectMeta{Name: "cross-namespace", Namespace: "default"}, - Spec: accessv1.RedisAccessSpec{ - Connection: accessv1.ConnectionSpec{ - ExistingSecret: &secretName, - ExistingSecretNamespace: &secretNamespace, - }, - }, - } + excludedUsers, err := reconciler.resolveExcludedUsers(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(excludedUsers).To(HaveLen(2)) + Expect(excludedUsers).To(HaveKey("default")) + Expect(excludedUsers).To(HaveKey("ops-user")) + }) - _, err := reconciler.getConnectionDetails(context.Background(), redisAccess) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("multiple Controller resources found")) + It("should default stale user deletion policy to Restrict", func() { + reconciler := &RedisAccessReconciler{} + policy, err := reconciler.resolveStaleUserDeletionPolicy(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(policy).To(Equal(accessv1.StaleUserDeletionPolicyRestrict)) }) - It("should normalize excluded usernames from singleton Controller settings", func() { - fakeClient, _ := controller.NewFakeClientWithScheme( - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "cluster-settings", Namespace: "system"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - RedisSettings: accessv1.RedisControllerSettings{ - ExcludedUsers: []string{" default ", "", "ops-user", "default"}, - }, - }, + It("should resolve stale user deletion policy from settings ConfigMap", func() { + deletePolicy := accessv1.StaleUserDeletionPolicyDelete + fakeClient, _ := test.NewFakeClientWithScheme( + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RedisSettings: accessv1.RedisControllerSettings{ + StaleUserDeletionPolicy: &deletePolicy, }, - }, + }), ) reconciler := &RedisAccessReconciler{Client: fakeClient} - excludedUsers, err := reconciler.resolveExcludedUsers(context.Background()) + policy, err := reconciler.resolveStaleUserDeletionPolicy(context.Background()) Expect(err).NotTo(HaveOccurred()) - Expect(excludedUsers).To(HaveLen(2)) - Expect(excludedUsers).To(HaveKey("default")) - Expect(excludedUsers).To(HaveKey("ops-user")) + Expect(policy).To(Equal(accessv1.StaleUserDeletionPolicyDelete)) }) It("should reject reserved ACL directives in user rules", func() { @@ -356,7 +328,7 @@ var _ = Describe("RedisAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(redisAccess) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(redisAccess) eventRecorder := events.NewFakeRecorder(5) reconciler := &RedisAccessReconciler{ Client: fakeClient, @@ -380,7 +352,7 @@ var _ = Describe("RedisAccess Controller", func() { Expect(updated.Status.LastReconcileState).To(Equal(accessv1.ReconcileStateError)) Expect(updated.Status.LastLog).To(ContainSubstring("no valid connection details provided")) - event := controller.ReceiveEvents(eventRecorder.Events, 1) + event := test.ReceiveEvents(eventRecorder.Events, 1) Expect(event).To(ContainSubstring(redisAccessConnectionErrorReason)) }) @@ -420,18 +392,13 @@ var _ = Describe("RedisAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme( + fakeClient, fakeScheme := test.NewFakeClientWithScheme( redisAccess, - &accessv1.Controller{ - ObjectMeta: metav1.ObjectMeta{Name: "settings", Namespace: "system"}, - Spec: accessv1.ControllerSpec{ - Settings: accessv1.ControllerSettings{ - RedisSettings: accessv1.RedisControllerSettings{ - ExcludedUsers: []string{"default"}, - }, - }, + test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RedisSettings: accessv1.RedisControllerSettings{ + ExcludedUsers: []string{"default"}, }, - }, + }), ) reconciler := &RedisAccessReconciler{ @@ -489,7 +456,7 @@ var _ = Describe("RedisAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(deleting, other) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(deleting, other) reconciler := &RedisAccessReconciler{ Client: fakeClient, Scheme: fakeScheme, @@ -545,7 +512,7 @@ var _ = Describe("RedisAccess Controller", func() { }, } - fakeClient, fakeScheme := controller.NewFakeClientWithScheme(deleting, other) + fakeClient, fakeScheme := test.NewFakeClientWithScheme(deleting, other) reconciler := &RedisAccessReconciler{ Client: fakeClient, Scheme: fakeScheme, @@ -557,6 +524,96 @@ var _ = Describe("RedisAccess Controller", func() { Expect(finalized).To(BeTrue()) Expect(mockState.deleteCalls()).To(BeEmpty()) }) + + It("should skip finalizer user deletion when stale user deletion policy is Restrict", func() { + now := metav1.Now() + host := testRedisHost + port := int32(6379) + adminUsername := testRedisAdminUsername + adminPassword := testRedisAdminPassword + mockState := newMockACLState() + Expect(mockState.SetUser(context.Background(), "stale-user", "reset", "on", ">secret", "~cache:*", "+get")).To(Succeed()) + + redisAccess := &accessv1.RedisAccess{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deleting", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{redisAccessFinalizer}, + }, + Spec: accessv1.RedisAccessSpec{ + GeneratedSecret: "deleting-secret", + Username: "stale-user", + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Username: &accessv1.SecretKeySelector{Value: &adminUsername}, + Password: &accessv1.SecretKeySelector{Value: &adminPassword}, + }, + ACLRules: []string{"~cache:*", "+get"}, + }, + } + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(redisAccess) + reconciler := &RedisAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + NewACLClient: newMockACLFactory(mockState), + } + + finalized, err := reconciler.finalizeRedisAccess(context.Background(), redisAccess) + Expect(err).NotTo(HaveOccurred()) + Expect(finalized).To(BeTrue()) + Expect(mockState.deleteCalls()).To(BeEmpty()) + }) + + It("should delete finalizer users when stale user deletion policy is Delete", func() { + now := metav1.Now() + host := testRedisHost + port := int32(6379) + adminUsername := testRedisAdminUsername + adminPassword := testRedisAdminPassword + deletePolicy := accessv1.StaleUserDeletionPolicyDelete + mockState := newMockACLState() + Expect(mockState.SetUser(context.Background(), "stale-user", "reset", "on", ">secret", "~cache:*", "+get")).To(Succeed()) + + redisAccess := &accessv1.RedisAccess{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deleting", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{redisAccessFinalizer}, + }, + Spec: accessv1.RedisAccessSpec{ + GeneratedSecret: "deleting-secret", + Username: "stale-user", + Connection: accessv1.ConnectionSpec{ + Host: &host, + Port: &port, + Username: &accessv1.SecretKeySelector{Value: &adminUsername}, + Password: &accessv1.SecretKeySelector{Value: &adminPassword}, + }, + ACLRules: []string{"~cache:*", "+get"}, + }, + } + controllerSettings := test.NewControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + RedisSettings: accessv1.RedisControllerSettings{ + StaleUserDeletionPolicy: &deletePolicy, + }, + }) + + fakeClient, fakeScheme := test.NewFakeClientWithScheme(redisAccess, controllerSettings) + reconciler := &RedisAccessReconciler{ + Client: fakeClient, + Scheme: fakeScheme, + NewACLClient: newMockACLFactory(mockState), + } + + finalized, err := reconciler.finalizeRedisAccess(context.Background(), redisAccess) + Expect(err).NotTo(HaveOccurred()) + Expect(finalized).To(BeTrue()) + Expect(mockState.deleteCalls()).To(Equal([]string{"stale-user"})) + }) }) }) diff --git a/internal/controller/shared_config_logic.go b/internal/controller/shared_config.go similarity index 66% rename from internal/controller/shared_config_logic.go rename to internal/controller/shared_config.go index d3dbb2a..d5d8fee 100644 --- a/internal/controller/shared_config_logic.go +++ b/internal/controller/shared_config.go @@ -7,10 +7,24 @@ import ( accessv1 "github.com/delta10/access-operator/api/v1" appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" ) -type SharedControllerMultipleHandler func(*accessv1.Controller, string) +const ( + ControllerSettingsConfigMapName = "access-operator-settings" + ControllerSettingsConfigMapKey = "settings.yaml" + + managerControlPlaneLabelKey = "control-plane" + managerControlPlaneLabelValue = "controller-manager" + managerAppNameLabelKey = "app.kubernetes.io/name" + managerAppNameLabelValue = "access-operator" + + defaultManagerDeploymentNamespace = "access-operator-system" +) type SharedConnectionDetails struct { Username string @@ -67,7 +81,6 @@ func ResolveConnectionSecretNamespace( c client.Client, resourceNamespace string, requestedNamespace *string, - onMultiple SharedControllerMultipleHandler, ) (string, error) { secretNamespace := resourceNamespace if requestedNamespace == nil { @@ -83,7 +96,7 @@ func ResolveConnectionSecretNamespace( return requested, nil } - allowed, err := resolveExistingSecretNamespacePolicy(ctx, c, onMultiple) + allowed, err := resolveExistingSecretNamespacePolicy(ctx, c) if err != nil { return "", err } @@ -100,17 +113,42 @@ func ResolveConnectionSecretNamespace( func ResolveControllerSettings( ctx context.Context, c client.Client, - onMultiple SharedControllerMultipleHandler, ) (accessv1.ControllerSettings, error) { - controllerObj, err := resolveSingletonController(ctx, c, onMultiple) + operatorNamespace, err := resolveOperatorNamespace(ctx, c) if err != nil { return accessv1.ControllerSettings{}, err } - if controllerObj == nil { + + configMapKey := types.NamespacedName{ + Name: ControllerSettingsConfigMapName, + Namespace: operatorNamespace, + } + + var configMap corev1.ConfigMap + if err := c.Get(ctx, configMapKey, &configMap); err != nil { + if apierrors.IsNotFound(err) { + return accessv1.ControllerSettings{}, nil + } + return accessv1.ControllerSettings{}, err + } + + rawSettings := strings.TrimSpace(configMap.Data[ControllerSettingsConfigMapKey]) + if rawSettings == "" { return accessv1.ControllerSettings{}, nil } - return controllerObj.Spec.Settings, nil + var settings accessv1.ControllerSettings + if err := yaml.Unmarshal([]byte(rawSettings), &settings); err != nil { + return accessv1.ControllerSettings{}, fmt.Errorf( + "failed to parse ConfigMap %s/%s data[%q]: %w", + configMap.Namespace, + configMap.Name, + ControllerSettingsConfigMapKey, + err, + ) + } + + return settings, nil } func ListManagerDeployments(ctx context.Context, c client.Client) ([]appsv1.Deployment, error) { @@ -141,3 +179,7 @@ func NormalizeExcludedUsers(users []string) map[string]struct{} { return normalized } + +func IsControllerSettingsConfigMap(obj client.Object) bool { + return obj != nil && obj.GetName() == ControllerSettingsConfigMapName +} diff --git a/internal/controller/shared_config_logic_test.go b/internal/controller/shared_config_logic_test.go new file mode 100644 index 0000000..a93af17 --- /dev/null +++ b/internal/controller/shared_config_logic_test.go @@ -0,0 +1,117 @@ +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" + + accessv1 "github.com/delta10/access-operator/api/v1" +) + +var _ = Describe("Shared config logic", func() { + Context("ResolveControllerSettings", func() { + It("should return zero settings when ConfigMap does not exist", func() { + fakeClient := newFakeClientWithScheme() + + settings, err := ResolveControllerSettings(context.Background(), fakeClient) + Expect(err).NotTo(HaveOccurred()) + Expect(settings).To(Equal(accessv1.ControllerSettings{})) + }) + + It("should parse settings from ConfigMap payload", func() { + fakeClient := newFakeClientWithScheme( + newControllerSettingsConfigMap("access-operator-system", accessv1.ControllerSettings{ + ExistingSecretNamespace: true, + PostgresSettings: accessv1.PostgresControllerSettings{ + ExcludedUsers: []string{"postgres"}, + }, + }), + ) + + settings, err := ResolveControllerSettings(context.Background(), fakeClient) + Expect(err).NotTo(HaveOccurred()) + Expect(settings.ExistingSecretNamespace).To(BeTrue()) + Expect(settings.PostgresSettings.ExcludedUsers).To(Equal([]string{"postgres"})) + }) + + It("should ignore ConfigMap outside operator namespace", func() { + fakeClient := newFakeClientWithScheme( + newControllerSettingsConfigMap("tenant-a", accessv1.ControllerSettings{ + ExistingSecretNamespace: true, + }), + ) + + settings, err := ResolveControllerSettings(context.Background(), fakeClient) + Expect(err).NotTo(HaveOccurred()) + Expect(settings).To(Equal(accessv1.ControllerSettings{})) + }) + + It("should return parse error for malformed ConfigMap data", func() { + fakeClient := newFakeClientWithScheme( + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: ControllerSettingsConfigMapName, + Namespace: "access-operator-system", + }, + Data: map[string]string{ + ControllerSettingsConfigMapKey: "existingSecretNamespace: [", + }, + }, + ) + + _, err := ResolveControllerSettings(context.Background(), fakeClient) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse ConfigMap access-operator-system/access-operator-settings")) + }) + }) + + Context("IsControllerSettingsConfigMap", func() { + It("should match only fixed ConfigMap name", func() { + Expect(IsControllerSettingsConfigMap(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: ControllerSettingsConfigMapName}, + })).To(BeTrue()) + + Expect(IsControllerSettingsConfigMap(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "other"}, + })).To(BeFalse()) + }) + }) +}) + +func newFakeClientWithScheme(objs ...client.Object) client.Client { + testScheme := runtime.NewScheme() + Expect(accessv1.AddToScheme(testScheme)).To(Succeed()) + Expect(corev1.AddToScheme(testScheme)).To(Succeed()) + Expect(appsv1.AddToScheme(testScheme)).To(Succeed()) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithStatusSubresource(&accessv1.PostgresAccess{}, &accessv1.RabbitMQAccess{}, &accessv1.RedisAccess{}). + WithObjects(objs...). + Build() + + return fakeClient +} + +func newControllerSettingsConfigMap(namespace string, settings accessv1.ControllerSettings) *corev1.ConfigMap { + rawConfig, err := yaml.Marshal(settings) + Expect(err).NotTo(HaveOccurred()) + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: ControllerSettingsConfigMapName, + Namespace: namespace, + }, + Data: map[string]string{ + ControllerSettingsConfigMapKey: string(rawConfig), + }, + } +} diff --git a/internal/controller/shared_reconciliation_logic.go b/internal/controller/shared_reconciliation.go similarity index 100% rename from internal/controller/shared_reconciliation_logic.go rename to internal/controller/shared_reconciliation.go diff --git a/internal/controller/shared_secret_logic.go b/internal/controller/shared_secret.go similarity index 100% rename from internal/controller/shared_secret_logic.go rename to internal/controller/shared_secret.go diff --git a/internal/controller/shared_test_logic.go b/internal/controller/shared_test_logic.go deleted file mode 100644 index 037dcd4..0000000 --- a/internal/controller/shared_test_logic.go +++ /dev/null @@ -1,39 +0,0 @@ -package controller - -import ( - "strings" - - accessv1 "github.com/delta10/access-operator/api/v1" - "github.com/onsi/gomega" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func NewFakeClientWithScheme(objs ...client.Object) (client.Client, *runtime.Scheme) { - testScheme := runtime.NewScheme() - gomega.Expect(accessv1.AddToScheme(testScheme)).To(gomega.Succeed()) - gomega.Expect(corev1.AddToScheme(testScheme)).To(gomega.Succeed()) - gomega.Expect(appsv1.AddToScheme(testScheme)).To(gomega.Succeed()) - - fakeClient := fake.NewClientBuilder(). - WithScheme(testScheme). - WithStatusSubresource(&accessv1.PostgresAccess{}, &accessv1.RabbitMQAccess{}, &accessv1.RedisAccess{}, &accessv1.Controller{}). - WithObjects(objs...). - Build() - - return fakeClient, testScheme -} - -func ReceiveEvents(events <-chan string, count int) string { - received := make([]string, 0, count) - for range count { - var event string - gomega.Eventually(events).Should(gomega.Receive(&event)) - received = append(received, event) - } - - return strings.Join(received, " ") -} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 328f9a8..67ecc26 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -27,10 +27,9 @@ import ( "testing" "time" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - - "github.com/delta10/access-operator/test/utils" ) var ( @@ -55,42 +54,53 @@ var _ = SynchronizedBeforeSuite(func() []byte { SetDefaultEventuallyPollingInterval(time.Second) By("building the manager image") - _, err := utils.RunCommandWithTimeout(10*time.Minute, "make", "docker-build", fmt.Sprintf("IMG=%s", managerImage)) + _, err := e2eutils.RunCommandWithTimeout(10*time.Minute, "make", "docker-build", fmt.Sprintf("IMG=%s", managerImage)) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager image") // TODO(user): If you want to change the e2e test vendor from Kind, // ensure the image is built and available, then remove the following block. By("loading the manager image on Kind") - err = utils.LoadImageToKindClusterWithName(managerImage) + err = e2eutils.LoadImageToKindClusterWithName(managerImage) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager image into Kind") setupCertManager() By("creating manager namespace") - _, err = utils.RunCommandWithTimeout(30*time.Second, "kubectl", "create", "ns", namespace) + _, err = e2eutils.RunCommandWithTimeout(30*time.Second, "kubectl", "create", "ns", namespace) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create namespace") By("labeling the namespace to enforce the restricted security policy") - _, err = utils.RunCommandWithTimeout(30*time.Second, "kubectl", "label", "--overwrite", "ns", namespace, + _, err = e2eutils.RunCommandWithTimeout(30*time.Second, "kubectl", "label", "--overwrite", "ns", namespace, "pod-security.kubernetes.io/enforce=restricted") ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") By("installing CRDs") - _, err = utils.RunCommandWithTimeout(5*time.Minute, "make", "install") + _, err = e2eutils.RunCommandWithTimeout(5*time.Minute, "make", "install") ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to install CRDs") By("waiting for operator CRDs to become established") - err = utils.WaitForCRDsEstablished( - "controllers.access.k8s.delta10.nl", + err = e2eutils.WaitForCRDsEstablished( "postgresaccesses.access.k8s.delta10.nl", "rabbitmqaccesses.access.k8s.delta10.nl", "redisaccesses.access.k8s.delta10.nl", ) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to wait for operator CRDs") + By("waiting for operator API resources to become discoverable") + err = e2eutils.WaitForAPIResources( + "access.k8s.delta10.nl", + "postgresaccesses", + "rabbitmqaccesses", + "redisaccesses", + ) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to wait for operator API discovery") + By("deploying the controller-manager") - _, err = utils.RunCommandWithTimeout(5*time.Minute, "make", "deploy", fmt.Sprintf("IMG=%s", managerImage)) + _, err = e2eutils.RunCommandWithTimeout(5*time.Minute, "make", "deploy", fmt.Sprintf("IMG=%s", managerImage)) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") + + By("removing any stale controller settings before parallel specs start") + clearAllControllerSettingsConfigMaps() return nil }, func(_ []byte) { SetDefaultEventuallyTimeout(2 * time.Minute) @@ -104,7 +114,7 @@ var _ = SynchronizedAfterSuite(func() { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) - _, _ = utils.Run(cmd) + _, _ = e2eutils.Run(cmd) } By("cleaning up the curl pod for metrics") @@ -140,7 +150,7 @@ func setupCertManager() { } By("checking if CertManager is already installed") - if utils.IsCertManagerCRDsInstalled() { + if e2eutils.IsCertManagerCRDsInstalled() { _, _ = fmt.Fprintf(GinkgoWriter, "CertManager is already installed. Skipping installation.\n") return } @@ -149,7 +159,7 @@ func setupCertManager() { shouldCleanupCertManager = true By("installing CertManager") - Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") + Expect(e2eutils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") } // teardownCertManager uninstalls CertManager if it was installed by setupCertManager. @@ -161,5 +171,5 @@ func teardownCertManager() { } By("uninstalling CertManager") - utils.UninstallCertManager() + e2eutils.UninstallCertManager() } diff --git a/test/e2e/manager_e2e_test.go b/test/e2e/manager_e2e_test.go index bf1576a..fd41f70 100644 --- a/test/e2e/manager_e2e_test.go +++ b/test/e2e/manager_e2e_test.go @@ -24,10 +24,9 @@ import ( "os/exec" "time" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - - "github.com/delta10/access-operator/test/utils" ) const namespace = "access-operator-system" @@ -48,7 +47,7 @@ var _ = AfterEach(func() { By("Fetching controller manager pod logs") cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) - controllerLogs, err := utils.Run(cmd) + controllerLogs, err := e2eutils.Run(cmd) if err == nil { _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) } else { @@ -57,7 +56,7 @@ var _ = AfterEach(func() { By("Fetching Kubernetes events") cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") - eventsOutput, err := utils.Run(cmd) + eventsOutput, err := e2eutils.Run(cmd) if err == nil { _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) } else { @@ -66,7 +65,7 @@ var _ = AfterEach(func() { By("Fetching curl-metrics logs") cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) - metricsOutput, err := utils.Run(cmd) + metricsOutput, err := e2eutils.Run(cmd) if err == nil { _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) } else { @@ -75,7 +74,7 @@ var _ = AfterEach(func() { By("Fetching controller manager pod description") cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace) - podDescription, err := utils.Run(cmd) + podDescription, err := e2eutils.Run(cmd) if err == nil { _, _ = fmt.Fprintf(GinkgoWriter, "Pod description:\n%s", podDescription) } else { @@ -91,22 +90,22 @@ var _ = Describe("Manager", Serial, Ordered, func() { It("should ensure the metrics endpoint is serving metrics", func() { By("recreating the ClusterRoleBinding for the service account to allow access to metrics") cmd := exec.Command("kubectl", "delete", "clusterrolebinding", metricsRoleBindingName, "--ignore-not-found=true") - _, _ = utils.Run(cmd) + _, _ = e2eutils.Run(cmd) cmd = exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName, "--clusterrole=access-operator-metrics-reader", fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName), ) - _, err := utils.Run(cmd) + _, err := e2eutils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") By("validating that the metrics service is available") cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace) - _, err = utils.Run(cmd) + _, err = e2eutils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") By("getting the service account token") - token, err := utils.ServiceAccountToken(namespace, serviceAccountName) + token, err := e2eutils.ServiceAccountToken(namespace, serviceAccountName) Expect(err).NotTo(HaveOccurred()) Expect(token).NotTo(BeEmpty()) @@ -116,7 +115,7 @@ var _ = Describe("Manager", Serial, Ordered, func() { Eventually(func(g Gomega) { cmd = exec.Command("kubectl", "get", "pod", controllerPodName, "-n", namespace, "-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}") - output, getErr := utils.Run(cmd) + output, getErr := e2eutils.Run(cmd) g.Expect(getErr).NotTo(HaveOccurred()) g.Expect(output).To(Equal("True"), "Controller pod not ready") }, 3*time.Minute, time.Second).Should(Succeed()) @@ -124,7 +123,7 @@ var _ = Describe("Manager", Serial, Ordered, func() { By("verifying that the controller manager is serving the metrics server") Eventually(func(g Gomega) { cmd = exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) - output, logErr := utils.Run(cmd) + output, logErr := e2eutils.Run(cmd) g.Expect(logErr).NotTo(HaveOccurred()) g.Expect(output).To(ContainSubstring("Serving metrics server"), "Metrics server not yet started") @@ -134,7 +133,7 @@ var _ = Describe("Manager", Serial, Ordered, func() { By("recreating the curl-metrics pod to access the metrics endpoint") cmd = exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace, "--ignore-not-found=true", "--wait=false") - _, _ = utils.Run(cmd) + _, _ = e2eutils.Run(cmd) cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never", "--namespace", namespace, @@ -163,7 +162,7 @@ var _ = Describe("Manager", Serial, Ordered, func() { "serviceAccountName": "%s" } }`, token, metricsServiceName, namespace, serviceAccountName)) - _, err = utils.Run(cmd) + _, err = e2eutils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") By("waiting for the curl-metrics pod to complete") @@ -171,14 +170,14 @@ var _ = Describe("Manager", Serial, Ordered, func() { cmd = exec.Command("kubectl", "get", "pods", "curl-metrics", "-o", "jsonpath={.status.phase}", "-n", namespace) - output, getErr := utils.Run(cmd) + output, getErr := e2eutils.Run(cmd) g.Expect(getErr).NotTo(HaveOccurred()) g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") }, 5*time.Minute).Should(Succeed()) By("getting the metrics by checking curl-metrics logs") Eventually(func(g Gomega) { - metricsOutput, logErr := utils.GetMetricsOutput(namespace, "curl-metrics") + metricsOutput, logErr := e2eutils.GetMetricsOutput(namespace, "curl-metrics") g.Expect(logErr).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") g.Expect(metricsOutput).NotTo(BeEmpty()) g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) @@ -194,9 +193,9 @@ func ensureControllerPodName() string { "-n", namespace, ) - podOutput, err := utils.Run(cmd) + podOutput, err := e2eutils.Run(cmd) g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") - podNames := utils.GetNonEmptyLines(podOutput) + podNames := e2eutils.GetNonEmptyLines(podOutput) g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") controllerPodName = podNames[0] g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) @@ -205,7 +204,7 @@ func ensureControllerPodName() string { "pods", controllerPodName, "-o", "jsonpath={.status.phase}", "-n", namespace, ) - output, getErr := utils.Run(cmd) + output, getErr := e2eutils.Run(cmd) g.Expect(getErr).NotTo(HaveOccurred()) g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") }).Should(Succeed()) diff --git a/test/e2e/parallel_support_test.go b/test/e2e/parallel_support_test.go index d1d23f9..b7c8c46 100644 --- a/test/e2e/parallel_support_test.go +++ b/test/e2e/parallel_support_test.go @@ -12,10 +12,9 @@ import ( "time" "github.com/delta10/access-operator/internal/controller" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - - "github.com/delta10/access-operator/test/utils" ) type sharedBackend struct { @@ -70,11 +69,11 @@ metadata: name: %s `, name) - Expect(utils.ApplyManifest(manifest)).To(Succeed(), "Failed to create namespace %s", name) + Expect(e2eutils.ApplyManifest(manifest)).To(Succeed(), "Failed to create namespace %s", name) Eventually(func(g Gomega) { cmd := exec.Command("kubectl", "get", "ns", name, "-o", "jsonpath={.status.phase}") - output, err := utils.Run(cmd) + output, err := e2eutils.Run(cmd) g.Expect(err).NotTo(HaveOccurred(), "Failed to get namespace %s", name) g.Expect(output).To(Equal("Active")) }, 30*time.Second, time.Second).Should(Succeed()) @@ -88,22 +87,38 @@ func createTestNamespace(prefix string) string { func deleteNamespace(name string) { cmd := exec.Command("kubectl", "delete", "ns", name, "--ignore-not-found", "--wait=false") - _, _ = utils.Run(cmd) + _, _ = e2eutils.Run(cmd) } func waitForNamespaceDeleted(name string) { Eventually(func(g Gomega) { cmd := exec.Command("kubectl", "get", "ns", name, "-o", "name", "--ignore-not-found") - output, err := utils.Run(cmd) + output, err := e2eutils.Run(cmd) g.Expect(err).NotTo(HaveOccurred(), "Failed to check namespace %s", name) g.Expect(output).To(BeEmpty()) }, 2*time.Minute, 2*time.Second).Should(Succeed()) } -func clearAllControllers() { - cmd := exec.Command("kubectl", "delete", "controller", "--all", "-A", "--ignore-not-found", "--wait=false") - _, _ = utils.Run(cmd) - waitForNoControllers() +func clearAllControllerSettingsConfigMaps() { + configMaps, err := listControllerSettingsConfigMaps() + Expect(err).NotTo(HaveOccurred(), "Failed to list settings ConfigMaps") + + for _, configMap := range configMaps { + cmd := exec.Command( + "kubectl", + "delete", + "configmap", + configMap.name, + "-n", + configMap.namespace, + "--ignore-not-found", + "--wait=false", + ) + _, err = e2eutils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to delete settings ConfigMap %s/%s", configMap.namespace, configMap.name) + } + + waitForNoControllerSettingsConfigMaps() } func ensureWorkerBackend( @@ -136,9 +151,9 @@ func ensurePostgresWorkerBackend() (string, controller.ConnectionDetails) { &postgresBackendOnce, &postgresBackend, "postgres-backend", - utils.DatabaseConnectionDetailsForNamespace, + e2eutils.DatabaseConnectionDetailsForNamespace, func(backendNamespace string, conn controller.ConnectionDetails) { - Expect(utils.DeployPostgresInstance(backendNamespace, conn)).To(Succeed(), + Expect(e2eutils.DeployPostgresInstance(backendNamespace, conn)).To(Succeed(), "Failed to deploy shared PostgreSQL backend") cmd := exec.Command( @@ -148,18 +163,18 @@ func ensurePostgresWorkerBackend() (string, controller.ConnectionDetails) { "deployment/postgres", "-n", backendNamespace, - "--timeout=2m", + "--timeout=5m", ) - _, err := utils.Run(cmd) + _, err := e2eutils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "PostgreSQL backend deployment should become available") Eventually(func(g Gomega) { - output, queryErr := utils.RunPostgresQuery(backendNamespace, conn, "SELECT 1;") + output, queryErr := e2eutils.RunPostgresQuery(backendNamespace, conn, "SELECT 1;") g.Expect(queryErr).NotTo(HaveOccurred(), "Shared PostgreSQL backend should accept connections") g.Expect(output).To(Equal("1")) }, 2*time.Minute, 5*time.Second).Should(Succeed()) - _, err = utils.RunPostgresQuery( + _, err = e2eutils.RunPostgresQuery( backendNamespace, conn, "CREATE TABLE IF NOT EXISTS public.access_operator_test(id SERIAL PRIMARY KEY, value TEXT);", @@ -174,11 +189,11 @@ func ensureRabbitMQWorkerBackend() (string, controller.ConnectionDetails) { &rabbitMQBackendOnce, &rabbitMQBackend, "rabbitmq-backend", - utils.RabbitMQConnectionDetailsForNamespace, + e2eutils.RabbitMQConnectionDetailsForNamespace, func(backendNamespace string, conn controller.ConnectionDetails) { - Expect(utils.DeployRabbitMQInstance(backendNamespace, conn)).To(Succeed(), + Expect(e2eutils.DeployRabbitMQInstance(backendNamespace, conn)).To(Succeed(), "Failed to deploy shared RabbitMQ backend") - utils.WaitForRabbitMQReady(backendNamespace) + e2eutils.WaitForRabbitMQReady(backendNamespace) }, ) } @@ -188,11 +203,11 @@ func ensureRedisWorkerBackend() (string, controller.ConnectionDetails) { &redisBackendOnce, &redisBackend, "redis-backend", - utils.RedisConnectionDetailsForNamespace, + e2eutils.RedisConnectionDetailsForNamespace, func(backendNamespace string, conn controller.ConnectionDetails) { - Expect(utils.DeployRedisInstance(backendNamespace, conn)).To(Succeed(), + Expect(e2eutils.DeployRedisInstance(backendNamespace, conn)).To(Succeed(), "Failed to deploy shared Redis backend") - utils.WaitForRedisReady(backendNamespace, conn) + e2eutils.WaitForRedisReady(backendNamespace, conn) }, ) } @@ -220,6 +235,7 @@ func newSpecEnv(prefix string, ensureBackend func() (string, controller.Connecti } func (e specEnv) cleanup() { + forceDeleteAccessResourcesInNamespace(e.namespace) deleteNamespace(e.namespace) } diff --git a/test/e2e/postgres_e2e_test.go b/test/e2e/postgres_e2e_test.go index 4dd8bc6..b140d85 100644 --- a/test/e2e/postgres_e2e_test.go +++ b/test/e2e/postgres_e2e_test.go @@ -24,11 +24,11 @@ import ( "fmt" "os/exec" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" accessv1 "github.com/delta10/access-operator/api/v1" - "github.com/delta10/access-operator/test/utils" ) var _ = Describe("Postgres", func() { @@ -55,7 +55,7 @@ spec: - CONNECT `, resourceName, env.namespace, generatedSecretName, resourceName) - err := utils.ApplyManifest(invalidResource) + err := e2eutils.ApplyManifest(invalidResource) Expect(err).NotTo(HaveOccurred(), "Failed to create invalid PostgresAccess resource") By("verifying the PostgresAccess status reports the reconcile failure") @@ -72,27 +72,27 @@ spec: It("should create a PostgresAccess resource and create a database user with the specified privileges on a CNPG instance", func() { testNamespace := createTestNamespace("cnpg-test") DeferCleanup(func() { + forceDeleteAccessResourcesInNamespace(testNamespace) deleteNamespace(testNamespace) }) By("deploying a PGSQL instance for testing") - err := utils.DeployCNPGInstance(testNamespace) + err := e2eutils.DeployCNPGInstance(testNamespace) Expect(err).NotTo(HaveOccurred(), "Failed to deploy PGSQL instance") By("waiting for CNPG to accept SQL connections") - conn := utils.GetCNPGConnectionDetailsFromSecret(testNamespace, "cnpg-postgres-app") - utils.WaitForAuthenticationSuccess(testNamespace, conn, conn.Username, conn.Password) + conn := e2eutils.GetCNPGConnectionDetailsFromSecret(testNamespace, "cnpg-postgres-app") + e2eutils.WaitForAuthenticationSuccess(testNamespace, conn, conn.Username, conn.Password) resourceName := fmt.Sprintf("test-username-%s", uniqueSuffix()) generatedSecret := fmt.Sprintf("test-postgres-credentials-%s", uniqueSuffix()) By("creating a PostgresAccess resource referencing the connection secret") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, testNamespace, generatedSecret, "cnpg-postgres-app", - nil, accessv1.GrantSpec{ Database: conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -101,10 +101,10 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference") By("waiting for the generated secret to be created") - utils.WaitForSecretField(testNamespace, generatedSecret, "username") + e2eutils.WaitForSecretField(testNamespace, generatedSecret, "username") By("verifying the database user was created") - utils.WaitForDatabaseUserState(testNamespace, conn, resourceName, true) + e2eutils.WaitForDatabaseUserState(testNamespace, conn, resourceName, true) }) }) @@ -124,7 +124,7 @@ spec: generatedSecret := env.name("test-postgres-credentials") By("creating a PostgresAccess resource") - err := utils.CreatePostgresAccessWithDirectConnection( + err := e2eutils.CreatePostgresAccessWithDirectConnection( resourceName, env.namespace, generatedSecret, @@ -134,13 +134,13 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with connection details") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the database user was created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) By("verifying the privileges were granted") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) }) It("should create a PostgresAccess resource with connectivity as a secret reference and create a database user accordingly", func() { @@ -148,16 +148,15 @@ spec: generatedSecret := env.name("test-postgres-credentials-secret-ref") By("creating a secret with the connection details") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") By("creating a PostgresAccess resource referencing the connection secret") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -166,10 +165,10 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the database user was created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) }) It("should create a database user when connection is provided via direct connection details but user and pass via secret reference", func() { @@ -177,11 +176,11 @@ spec: generatedSecret := env.name("test-user-pass-secret") By("creating a secret with the username and password") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") By("creating a PostgresAccess resource referencing the username/password secret and providing connection details directly") - err = utils.CreatePostgresAccessWithConnectionSecretRef( + err = e2eutils.CreatePostgresAccessWithConnectionSecretRef( resourceName, env.namespace, generatedSecret, @@ -192,10 +191,10 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference for username/password") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the database user was created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) }) It("should reconcile privileges when they're changed in the config", func() { @@ -203,15 +202,14 @@ spec: generatedSecret := env.name("test-postgres-credentials-secret-ref") By("creating a PostgresAccess resource with certain privileges") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT"}, @@ -220,15 +218,14 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference") By("waiting for the initial privileges to be granted") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT"}) By("updating the PostgresAccess resource to include additional privileges") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT", "INSERT"}, @@ -237,7 +234,7 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to update PostgresAccess resource with new privileges") By("verifying that the new privileges are granted") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT", "INSERT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT", "INSERT"}) }) It("should reconcile the privileges of a PostgresAccess resource when they are manually revoked in the database", func() { @@ -245,15 +242,14 @@ spec: generatedSecret := env.name("test-postgres-credentials-secret-ref") By("creating a PostgresAccess resource") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -262,37 +258,36 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference") By("waiting for the privileges to be granted") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) By("revoking the SELECT privilege from the database user") - _, err = utils.RunPostgresQuery( + _, err = e2eutils.RunPostgresQuery( env.backendNamespace, env.conn, fmt.Sprintf(`REVOKE SELECT ON ALL TABLES IN SCHEMA public FROM "%s";`, resourceName), ) Expect(err).NotTo(HaveOccurred(), "Failed to revoke SELECT privilege") - err = utils.TriggerReconciliation("postgresaccess", resourceName, env.namespace) + err = e2eutils.TriggerReconciliation("postgresaccess", resourceName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to trigger reconciliation after revoking privileges") By("verifying that the controller reconciles and restores the revoked privilege") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) }) - It("should delete the database user and secrets when the PostgresAccess resource is deleted", func() { + It("should retain the database user and delete the generated secret when the PostgresAccess resource is deleted by default", func() { resourceName := env.name("test-deletion") generatedSecret := env.name("test-postgres-credentials-secret-ref") By("creating a PostgresAccess resource") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -301,56 +296,64 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference") By("waiting for the privileges to be granted") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) By("deleting the PostgresAccess resource") - err = utils.DeletePostgresAccess(resourceName, env.namespace) + err = e2eutils.DeletePostgresAccess(resourceName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete PostgresAccess resource") By("verifying finalization removed the PostgresAccess resource") - utils.WaitForResourceDeleted("postgresaccess", resourceName, env.namespace) + e2eutils.WaitForResourceDeleted("postgresaccess", resourceName, env.namespace) - By("verifying that the database user is deleted") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + By("verifying that the database user is retained by default policy") + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) By("verifying that the generated secret is deleted") - utils.WaitForSecretDeleted(env.namespace, generatedSecret) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) }) - It("should reassign owned objects to the database owner when cleanupPolicy is Orphan", func() { + It("should reassign owned objects to the database owner when stale user deletion policy is Orphan", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + managedUsername := env.name("test-orphan-cleanup") generatedSecret := env.name("test-orphan-cleanup-credentials") ownedTable := env.name("orphan-policy-owned-table") + By("creating a settings ConfigMap with staleUserDeletionPolicy Orphan") + err := createControllerSettingsConfigMap(namespace, `postgres: + staleUserDeletionPolicy: Orphan`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap with Orphan policy") + DeferCleanup(func() { + deleteControllerSettingsConfigMap(namespace) + }) - By("creating a PostgresAccess resource with cleanupPolicy Orphan") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + By("creating a PostgresAccess resource") + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") - orphanPolicy := accessv1.CleanupPolicyOrphan - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( managedUsername, env.namespace, generatedSecret, secretName, - &orphanPolicy, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "USAGE", "CREATE"}, }, ) - Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess with cleanupPolicy Orphan") + Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess with Orphan controller policy") By("waiting for the generated secret to be created and reading the managed password") - managedPassword := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + managedPassword := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") By("waiting for the managed user to be created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, true) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, true) By("creating an object owned by the managed user") managedConn := env.conn managedConn.Username = managedUsername managedConn.Password = managedPassword - _, err = utils.RunPostgresQuery( + _, err = e2eutils.RunPostgresQuery( env.backendNamespace, managedConn, fmt.Sprintf(`CREATE TABLE public.%q (id SERIAL PRIMARY KEY, value TEXT);`, ownedTable), @@ -358,17 +361,17 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create an owned object as the managed user") By("verifying the object is initially owned by the managed user") - utils.WaitForTableOwner(env.backendNamespace, env.conn, ownedTable, managedUsername) + e2eutils.WaitForTableOwner(env.backendNamespace, env.conn, ownedTable, managedUsername) By("deleting the PostgresAccess resource") - err = utils.DeletePostgresAccess(managedUsername, env.namespace) + err = e2eutils.DeletePostgresAccess(managedUsername, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete PostgresAccess resource") By("verifying that the managed role is deleted") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, false) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, false) By("verifying ownership is reassigned to the current database owner") - utils.WaitForTableOwner(env.backendNamespace, env.conn, ownedTable, env.conn.Username) + e2eutils.WaitForTableOwner(env.backendNamespace, env.conn, ownedTable, env.conn.Username) }) It("should update the database user's password when the PostgresAccess resource is updated with a new password", func() { @@ -376,15 +379,14 @@ spec: generatedSecret := env.name("test-postgres-credentials-secret-ref") By("creating a PostgresAccess resource") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -393,7 +395,7 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference") By("waiting for the privileges to be granted") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) By("updating the PostgresAccess generated secret with a new password") newPassword := "new-secure-password" @@ -408,11 +410,11 @@ data: password: %s `, generatedSecret, env.namespace, b64.StdEncoding.EncodeToString([]byte(resourceName)), b64.StdEncoding.EncodeToString([]byte(newPassword))) - err = utils.ApplyManifest(updatedSecretYAML) + err = e2eutils.ApplyManifest(updatedSecretYAML) Expect(err).NotTo(HaveOccurred(), "Failed to update generated secret with new password") By("verifying that the database user's password is updated and the user can authenticate with the new password") - utils.WaitForAuthenticationSuccess(env.backendNamespace, env.conn, resourceName, newPassword) + e2eutils.WaitForAuthenticationSuccess(env.backendNamespace, env.conn, resourceName, newPassword) }) It("should update the database user's password the secret's password is rolled via deletion", func() { @@ -420,15 +422,14 @@ data: generatedSecret := env.name("test-postgres-credentials-secret-ref") By("creating a PostgresAccess resource") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") - err = utils.CreateResourceFromSecretReference( + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -437,33 +438,31 @@ data: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with secret reference") By("waiting for the privileges to be granted") - utils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) + e2eutils.WaitForPrivilegesGranted(env.backendNamespace, env.conn, resourceName, []string{"CONNECT", "SELECT"}) By("deleting the secret to trigger password rotation") cmd := exec.Command("kubectl", "delete", "secret", generatedSecret, "-n", env.namespace) - _, err = utils.Run(cmd) + _, err = e2eutils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to delete generated secret") By("verifying that the database user's password is updated and the user can authenticate with the new password") - newPassword := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") - utils.WaitForAuthenticationSuccess(env.backendNamespace, env.conn, resourceName, newPassword) + newPassword := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + e2eutils.WaitForAuthenticationSuccess(env.backendNamespace, env.conn, resourceName, newPassword) }) }) - Context("Controller policy", Serial, func() { + Context("Settings ConfigMap policy", func() { var env postgresSpecEnv BeforeEach(func() { - clearAllControllers() env = newPostgresSpecEnv() }) AfterEach(func() { env.cleanup() - clearAllControllers() }) - It("should deny cross-namespace existingSecret when no Controller resource exists", func() { + It("should deny cross-namespace existingSecret when no settings ConfigMap exists", func() { resourceName := env.name("test-cross-namespace-no-controller") generatedSecret := env.name("test-cross-namespace-no-controller-secret") connectionSecretNamespace := createTestNamespace("postgres-shared-no-controller") @@ -472,17 +471,16 @@ data: }) By("creating the connection secret in another namespace") - secretName, err := utils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a PostgresAccess that references the shared secret namespace") - err = utils.CreateResourceFromSecretReferenceWithNamespace( + err = e2eutils.CreateResourceFromSecretReferenceWithNamespace( resourceName, env.namespace, generatedSecret, secretName, connectionSecretNamespace, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -498,37 +496,41 @@ data: }) By("verifying the requested database user was not created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + + By("removing the denied PostgresAccess resource without running its unreachable finalizer") + forceDeleteAccessResource("postgresaccess", namespacedName{name: resourceName, namespace: env.namespace}) }) - It("should deny cross-namespace existingSecret when singleton Controller setting is false", func() { + It("should deny cross-namespace existingSecret when settings ConfigMap setting is false", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + resourceName := env.name("test-cross-namespace-controller-false") generatedSecret := env.name("test-cross-namespace-controller-false-secret") - controllerName := env.name("cluster-settings-false") connectionSecretNamespace := createTestNamespace("postgres-shared-controller-false") DeferCleanup(func() { deleteNamespace(connectionSecretNamespace) }) - By("creating a singleton Controller with existingSecretNamespace=false") - err := createControllerResource(controllerName, namespace, `existingSecretNamespace: false`) - Expect(err).NotTo(HaveOccurred(), "Failed to create singleton Controller with false policy") + By("creating settings ConfigMap with existingSecretNamespace=false") + err := createControllerSettingsConfigMap(namespace, `existingSecretNamespace: false`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap with false policy") DeferCleanup(func() { - deleteControllerResource(controllerName, namespace) + deleteControllerSettingsConfigMap(namespace) }) By("creating the connection secret in another namespace") - secretName, err := utils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a PostgresAccess that references the shared secret namespace") - err = utils.CreateResourceFromSecretReferenceWithNamespace( + err = e2eutils.CreateResourceFromSecretReferenceWithNamespace( resourceName, env.namespace, generatedSecret, secretName, connectionSecretNamespace, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -536,43 +538,47 @@ data: ) Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace PostgresAccess") - By("verifying reconcile is denied because singleton Controller policy is false") + By("verifying reconcile is denied because settings ConfigMap policy is false") waitForReadyCondition("postgresaccess", namespacedName{name: resourceName, namespace: env.namespace}, readyConditionExpectation{ messageContains: "cross-namespace connection secret references are disabled", }) By("verifying the requested database user was not created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + + By("removing the denied PostgresAccess resource without running its unreachable finalizer") + forceDeleteAccessResource("postgresaccess", namespacedName{name: resourceName, namespace: env.namespace}) }) - It("should create a PostgresAccess resource using an existing connection secret from another namespace", func() { + It("should create a PostgresAccess resource using an existing connection secret from another namespace", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + resourceName := env.name("test-username-cross-namespace") generatedSecret := env.name("test-postgres-credentials-cross-namespace") - controllerName := env.name("cluster-settings") connectionSecretNamespace := createTestNamespace("postgres-shared") DeferCleanup(func() { deleteNamespace(connectionSecretNamespace) }) - By("enabling cross-namespace references through the singleton Controller resource") - err := createControllerResource(controllerName, namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to enable cross-namespace references via Controller CR") + By("enabling cross-namespace references through operator settings ConfigMap") + err := createControllerSettingsConfigMap(namespace, `existingSecretNamespace: true`) + Expect(err).NotTo(HaveOccurred(), "Failed to enable cross-namespace references via settings ConfigMap") DeferCleanup(func() { - deleteControllerResource(controllerName, namespace) + deleteControllerSettingsConfigMap(namespace) }) By("creating the connection secret in the shared namespace") - secretName, err := utils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a PostgresAccess resource in the workload namespace that references the shared secret") - err = utils.CreateResourceFromSecretReferenceWithNamespace( + err = e2eutils.CreateResourceFromSecretReferenceWithNamespace( resourceName, env.namespace, generatedSecret, secretName, connectionSecretNamespace, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -581,40 +587,32 @@ data: Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with cross-namespace secret reference") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the database user was created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) }) - It("should deny cross-namespace existingSecret when the singleton Controller is outside the operator namespace", func() { + It("should deny cross-namespace existingSecret when settings ConfigMap is outside the operator namespace", func() { resourceName := env.name("test-cross-namespace-wrong-controller-namespace") generatedSecret := env.name("test-cross-namespace-wrong-controller-namespace-secret") - controllerName := env.name("cluster-settings-wrong-namespace") - connectionSecretNamespace := createTestNamespace("postgres-shared-wrong-controller-namespace") - DeferCleanup(func() { - deleteNamespace(connectionSecretNamespace) - }) + connectionSecretNamespace := env.name("postgres-shared-wrong-controller-namespace") + secretName := env.name("missing-connection-secret") - By("creating the singleton Controller in a workload namespace instead of the operator namespace") - err := createControllerResource(controllerName, env.namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to create singleton Controller outside the operator namespace") + By("creating settings ConfigMap in workload namespace instead of operator namespace") + err := createControllerSettingsConfigMap(env.namespace, `existingSecretNamespace: true`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap outside the operator namespace") DeferCleanup(func() { - deleteControllerResource(controllerName, env.namespace) + deleteControllerSettingsConfigMap(env.namespace) }) - By("creating the connection secret in another namespace") - secretName, err := utils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) - Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") - By("creating a PostgresAccess that references the shared secret namespace") - err = utils.CreateResourceFromSecretReferenceWithNamespace( + err = e2eutils.CreateResourceFromSecretReferenceWithNamespace( resourceName, env.namespace, generatedSecret, secretName, connectionSecretNamespace, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, @@ -622,137 +620,236 @@ data: ) Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace PostgresAccess") - By("verifying reconcile is denied because the Controller is not in the operator namespace") + By("verifying reconcile is denied because settings ConfigMap outside operator namespace is ignored") waitForReadyCondition("postgresaccess", namespacedName{name: resourceName, namespace: env.namespace}, readyConditionExpectation{ status: "False", reason: "DatabaseSyncFailed", - messageContains: `must be created in the operator namespace "access-operator-system"`, - }) - - By("verifying the misplaced Controller is marked not ready") - waitForReadyCondition("controller", namespacedName{name: controllerName, namespace: env.namespace}, readyConditionExpectation{ - status: "False", - reason: "InvalidControllerNamespace", - messageContains: `must be created in the operator namespace "access-operator-system"`, + messageContains: "cross-namespace connection secret references are disabled", }) By("verifying the requested database user was not created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + + By("removing the denied PostgresAccess resource without running its unreachable finalizer") + forceDeleteAccessResource("postgresaccess", namespacedName{name: resourceName, namespace: env.namespace}) }) - It("should fail when multiple Controller resources exist and emit warning events", func() { - resourceName := env.name("test-cross-namespace-multiple-controllers") - generatedSecret := env.name("test-cross-namespace-multiple-controllers-secret") - controllerAName := env.name("cluster-settings-a") - controllerBName := env.name("cluster-settings-b") - connectionSecretNamespace := createTestNamespace("postgres-shared-multiple-controller") + It("should preserve excluded PostgreSQL users from settings ConfigMap", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + + excludedUsername := env.name("excluded-keeper") + managedUsername := env.name("test-managed-user") + generatedSecret := env.name("test-excluded-user-secret") + + By("creating settings ConfigMap with excluded PostgreSQL users") + err := createControllerSettingsConfigMap(namespace, fmt.Sprintf(`postgres: + excludedUsers: + - %s`, excludedUsername)) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap with excluded users") DeferCleanup(func() { - deleteNamespace(connectionSecretNamespace) + deleteControllerSettingsConfigMap(namespace) }) - By("creating two Controller resources to violate singleton policy") - err := createControllerResource(controllerAName, namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to create first Controller") + By("creating an unmanaged PostgreSQL role that should be preserved") + _, err = e2eutils.RunPostgresQuery( + env.backendNamespace, + env.conn, + fmt.Sprintf(`CREATE ROLE "%s" WITH LOGIN PASSWORD 'keep-me';`, excludedUsername), + ) + Expect(err).NotTo(HaveOccurred(), "Failed to create excluded PostgreSQL role") + + By("creating a PostgresAccess resource to trigger reconciliation") + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") + + err = e2eutils.CreateResourceFromSecretReference( + managedUsername, + env.namespace, + generatedSecret, + secretName, + accessv1.GrantSpec{ + Database: env.conn.Database, + Privileges: []string{"CONNECT", "SELECT"}, + }, + ) + Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource") - err = createControllerResource(controllerBName, env.namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to create second Controller") + By("waiting for the generated secret to be created") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") - DeferCleanup(func() { - deleteControllerResource(controllerAName, namespace) - }) - DeferCleanup(func() { - deleteControllerResource(controllerBName, env.namespace) - }) + By("verifying the managed role is created") + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, true) - By("creating the connection secret in another namespace") - secretName, err := utils.CreateConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) - Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") + By("verifying the excluded unmanaged role is not removed") + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, excludedUsername, true) + }) - By("creating a PostgresAccess that references the shared secret namespace") - err = utils.CreateResourceFromSecretReferenceWithNamespace( + It("should retain a stale PostgreSQL role when stale user deletion policy is Restrict", func() { + resourceName := env.name("test-restrict-retain-role") + generatedSecret := env.name("test-restrict-retain-role-secret") + + By("creating a PostgresAccess resource") + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") + + err = e2eutils.CreateResourceFromSecretReference( resourceName, env.namespace, generatedSecret, secretName, - connectionSecretNamespace, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, }, ) - Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace PostgresAccess") + Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource") - By("verifying PostgresAccess fails with multiple-controller error") - waitForReadyCondition("postgresaccess", namespacedName{name: resourceName, namespace: env.namespace}, readyConditionExpectation{ - reason: "DatabaseSyncFailed", - messageContains: "multiple Controller resources found", - }) + By("waiting for the generated secret to be created") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") - By("verifying both Controller resources are marked Ready=False with MultipleControllersFound") - controllerResources := []namespacedName{ - {name: controllerAName, namespace: namespace}, - {name: controllerBName, namespace: env.namespace}, - } - waitForControllerResourcesReadyCondition(controllerResources, readyConditionExpectation{ - status: "False", - reason: "MultipleControllersFound", - }) + By("waiting for the managed role to exist") + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) - By("verifying warning events are emitted for both Controller resources") - for _, resource := range controllerResources { - waitForResourceWarningEvent(resource, "Controller", "MultipleControllersFound") - } + By("deleting the PostgresAccess resource") + err = e2eutils.DeletePostgresAccess(resourceName, env.namespace) + Expect(err).NotTo(HaveOccurred(), "Failed to delete PostgresAccess resource") - By("verifying warning event is emitted on controller-manager Deployment") - waitForResourceWarningEvent(namespacedName{name: managerDeploymentName, namespace: namespace}, "Deployment", "MultipleControllersFound") + By("verifying the managed role is retained by the default Restrict policy") + e2eutils.WaitForResourceDeleted("postgresaccess", resourceName, env.namespace) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) }) - It("should preserve excluded PostgreSQL users from singleton Controller settings", func() { - excludedUsername := env.name("excluded-keeper") - managedUsername := env.name("test-managed-user") - generatedSecret := env.name("test-excluded-user-secret") - controllerName := env.name("cluster-settings-excluded-users") - - By("creating a singleton Controller with excluded PostgreSQL users") - err := createControllerResource(controllerName, namespace, fmt.Sprintf(`postgres: - excludedUsers: - - %s`, excludedUsername)) - Expect(err).NotTo(HaveOccurred(), "Failed to create singleton Controller with excluded users") + It("should drop owned objects when stale user deletion policy is Cascade", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + + managedUsername := env.name("test-cascade-cleanup") + generatedSecret := env.name("test-cascade-cleanup-credentials") + ownedTable := env.name("cascade-policy-owned-table") + By("creating a settings ConfigMap with staleUserDeletionPolicy Cascade") + err := createControllerSettingsConfigMap(namespace, `postgres: + staleUserDeletionPolicy: Cascade`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap with Cascade policy") DeferCleanup(func() { - deleteControllerResource(controllerName, namespace) + deleteControllerSettingsConfigMap(namespace) }) - By("creating an unmanaged PostgreSQL role that should be preserved") - _, err = utils.RunPostgresQuery( + By("creating a PostgresAccess resource") + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") + + err = e2eutils.CreateResourceFromSecretReference( + managedUsername, + env.namespace, + generatedSecret, + secretName, + accessv1.GrantSpec{ + Database: env.conn.Database, + Privileges: []string{"CONNECT", "USAGE", "CREATE"}, + }, + ) + Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess with Cascade controller policy") + + By("waiting for the generated secret and managed role") + managedPassword := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, true) + + By("creating an object owned by the managed user") + managedConn := env.conn + managedConn.Username = managedUsername + managedConn.Password = managedPassword + _, err = e2eutils.RunPostgresQuery( env.backendNamespace, - env.conn, - fmt.Sprintf(`CREATE ROLE "%s" WITH LOGIN PASSWORD 'keep-me';`, excludedUsername), + managedConn, + fmt.Sprintf(`CREATE TABLE public.%q (id SERIAL PRIMARY KEY, value TEXT);`, ownedTable), ) - Expect(err).NotTo(HaveOccurred(), "Failed to create excluded PostgreSQL role") + Expect(err).NotTo(HaveOccurred(), "Failed to create an owned object as the managed user") - By("creating a PostgresAccess resource to trigger reconciliation") - secretName, err := utils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + By("deleting the PostgresAccess resource") + err = e2eutils.DeletePostgresAccess(managedUsername, env.namespace) + Expect(err).NotTo(HaveOccurred(), "Failed to delete PostgresAccess resource") + + By("verifying the managed role and owned table are removed") + e2eutils.WaitForResourceDeleted("postgresaccess", managedUsername, env.namespace) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, false) + e2eutils.WaitForTableMissing(env.backendNamespace, env.conn, ownedTable) + }) + + It("should delete the managed role during finalization when stale user deletion policy is Retain", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + + resourceName := env.name("test-retain-finalizer-delete") + generatedSecret := env.name("test-retain-finalizer-delete-secret") + By("creating a settings ConfigMap with staleUserDeletionPolicy Retain") + err := createControllerSettingsConfigMap(namespace, `postgres: + staleUserDeletionPolicy: Retain`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap with Retain policy") + DeferCleanup(func() { + deleteControllerSettingsConfigMap(namespace) + }) + + By("creating a PostgresAccess resource") + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") - err = utils.CreateResourceFromSecretReference( - managedUsername, + err = e2eutils.CreateResourceFromSecretReference( + resourceName, env.namespace, generatedSecret, secretName, - nil, accessv1.GrantSpec{ Database: env.conn.Database, Privileges: []string{"CONNECT", "SELECT"}, }, ) - Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource") + Expect(err).NotTo(HaveOccurred(), "Failed to create PostgresAccess resource with Retain controller policy") - By("verifying the managed role is created") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, managedUsername, true) + By("waiting for the generated secret to be created") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") - By("verifying the excluded unmanaged role is not removed") - utils.WaitForDatabaseUserState(env.backendNamespace, env.conn, excludedUsername, true) + By("waiting for the managed role to exist") + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, true) + + By("deleting the PostgresAccess resource") + err = e2eutils.DeletePostgresAccess(resourceName, env.namespace) + Expect(err).NotTo(HaveOccurred(), "Failed to delete PostgresAccess resource") + + By("verifying the managed role is deleted during finalization") + e2eutils.WaitForResourceDeleted("postgresaccess", resourceName, env.namespace) + e2eutils.WaitForDatabaseUserState(env.backendNamespace, env.conn, resourceName, false) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) + }) + + It("should reject PostgresAccess manifests that still use spec.cleanupPolicy", func() { + resourceName := env.name("test-cleanup-policy-schema-rejection") + generatedSecret := env.name("test-cleanup-policy-schema-rejection-secret") + + By("creating the connection secret referenced by the invalid manifest") + secretName, err := e2eutils.CreateConnectionDetailsViaSecret(env.namespace, env.conn) + Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret") + + invalidManifest := fmt.Sprintf(`apiVersion: access.k8s.delta10.nl/v1 +kind: PostgresAccess +metadata: + name: %s + namespace: %s +spec: + generatedSecret: %s + username: %s + cleanupPolicy: Orphan + connection: + existingSecret: %s + grants: + - database: %s + privileges: + - CONNECT +`, resourceName, env.namespace, generatedSecret, resourceName, secretName, env.conn.Database) + + err = e2eutils.ApplyManifestServerDryRun(invalidManifest) + Expect(err).To(HaveOccurred(), "PostgresAccess manifests using spec.cleanupPolicy should be rejected by the CRD schema") }) }) }) diff --git a/test/e2e/rabbitmq_e2e_test.go b/test/e2e/rabbitmq_e2e_test.go index 6dfb5ad..a526b91 100644 --- a/test/e2e/rabbitmq_e2e_test.go +++ b/test/e2e/rabbitmq_e2e_test.go @@ -24,11 +24,11 @@ import ( "os/exec" "strings" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" accessv1 "github.com/delta10/access-operator/api/v1" - "github.com/delta10/access-operator/test/utils" ) var _ = Describe("RabbitMQ", func() { @@ -65,7 +65,7 @@ spec: read: ".*" `, resourceName, env.namespace, generatedSecretName, resourceName, vhost) - err := utils.ApplyManifest(invalidResource) + err := e2eutils.ApplyManifest(invalidResource) Expect(err).NotTo(HaveOccurred(), "Failed to create invalid RabbitMQAccess resource") By("verifying the RabbitMQAccess status reports the reconcile failure") @@ -87,22 +87,22 @@ spec: } By("creating a RabbitMQAccess resource") - err := utils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) + err := e2eutils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource with connection details") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the RabbitMQ user and vhost were created") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, vhost, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, vhost, true) By("verifying the permissions were granted") - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) By("verifying the generated credentials can authenticate") - password := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") - utils.WaitForRabbitMQAuthenticationSuccess(env.backendNamespace, resourceName, password) + password := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + e2eutils.WaitForRabbitMQAuthenticationSuccess(env.backendNamespace, resourceName, password) }) It("should create a RabbitMQAccess resource with direct host/port and secret-referenced credentials", func() { @@ -114,19 +114,19 @@ spec: } By("creating a secret with the connection details") - secretName, err := utils.CreateRabbitMQConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateRabbitMQConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQ connection secret") By("creating a RabbitMQAccess resource referencing the username/password secret and providing host/port directly") - err = utils.CreateRabbitMQAccessWithConnectionSecretRef(resourceName, env.namespace, generatedSecret, env.conn, secretName, permissions) + err = e2eutils.CreateRabbitMQAccessWithConnectionSecretRef(resourceName, env.namespace, generatedSecret, env.conn, secretName, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource with secret references") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the RabbitMQ user and permissions were created") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) }) It("should create a RabbitMQAccess resource using an existing connection secret in the same namespace", func() { @@ -138,22 +138,22 @@ spec: } By("creating a secret with the connection details") - secretName, err := utils.CreateRabbitMQConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateRabbitMQConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQ connection secret") By("creating a RabbitMQAccess resource referencing the connection secret") - err = utils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecret, secretName, nil, permissions) + err = e2eutils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecret, secretName, nil, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource with existingSecret") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the RabbitMQ user and permissions were created") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) }) - It("should delete the RabbitMQ user and generated secret when the RabbitMQAccess resource is deleted", func() { + It("should retain the RabbitMQ user and vhost and delete the generated secret when the RabbitMQAccess resource is deleted by default", func() { resourceName := env.name("test-rabbitmq-deletion") generatedSecret := env.name("test-rabbitmq-deletion-secret") vhost := env.vhost("app-delete") @@ -162,22 +162,23 @@ spec: } By("creating a RabbitMQAccess resource") - err := utils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) + err := e2eutils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource") By("waiting for the generated secret, user, and permissions to exist") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) By("deleting the RabbitMQAccess resource") - err = utils.DeleteRabbitMQAccess(resourceName, env.namespace) + err = e2eutils.DeleteRabbitMQAccess(resourceName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete RabbitMQAccess resource") - By("verifying finalization removed the RabbitMQAccess, user, and generated secret") - utils.WaitForResourceDeleted("rabbitmqaccess", resourceName, env.namespace) - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) - utils.WaitForSecretDeleted(env.namespace, generatedSecret) + By("verifying finalization removed the RabbitMQAccess, retained the user and vhost, and deleted the generated secret") + e2eutils.WaitForResourceDeleted("rabbitmqaccess", resourceName, env.namespace) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, vhost, true) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) }) It("should reconcile permissions when they're changed in the config", func() { @@ -192,18 +193,18 @@ spec: } By("creating a RabbitMQAccess resource with certain permissions") - err := utils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, initialPermissions) + err := e2eutils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, initialPermissions) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource") By("waiting for the initial permissions to be granted") - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, initialPermissions) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, initialPermissions) By("updating the RabbitMQAccess resource to include new permissions") - err = utils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, updatedPermissions) + err = e2eutils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, updatedPermissions) Expect(err).NotTo(HaveOccurred(), "Failed to update RabbitMQAccess resource") By("verifying that the new permissions are granted") - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, updatedPermissions) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, updatedPermissions) }) It("should reconcile the permissions of a RabbitMQAccess resource when they are manually revoked", func() { @@ -215,21 +216,21 @@ spec: } By("creating a RabbitMQAccess resource") - err := utils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) + err := e2eutils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource") By("waiting for the permissions to be granted") - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) By("revoking the permissions from the RabbitMQ user") - _, err = utils.RunRabbitMQctl(env.backendNamespace, "clear_permissions", "-p", vhost, resourceName) + _, err = e2eutils.RunRabbitMQctl(env.backendNamespace, "clear_permissions", "-p", vhost, resourceName) Expect(err).NotTo(HaveOccurred(), "Failed to clear RabbitMQ permissions") - err = utils.TriggerReconciliation("rabbitmqaccess", resourceName, env.namespace) + err = e2eutils.TriggerReconciliation("rabbitmqaccess", resourceName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to trigger reconciliation after clearing permissions") By("verifying that the controller reconciles and restores the permissions") - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) }) It("should update the RabbitMQ user's password when the secret's password is rolled via deletion", func() { @@ -241,40 +242,40 @@ spec: } By("creating a RabbitMQAccess resource") - err := utils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) + err := e2eutils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource") By("waiting for the generated secret and initial authentication") - oldPassword := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") - utils.WaitForRabbitMQAuthenticationSuccess(env.backendNamespace, resourceName, oldPassword) + oldPassword := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + e2eutils.WaitForRabbitMQAuthenticationSuccess(env.backendNamespace, resourceName, oldPassword) By("deleting the generated secret to trigger password rotation") cmd := exec.Command("kubectl", "delete", "secret", generatedSecret, "-n", env.namespace) - _, err = utils.Run(cmd) + _, err = e2eutils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to delete generated secret") By("verifying that the RabbitMQ user's password is rotated and the new password authenticates") - newPassword := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + newPassword := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") Expect(newPassword).NotTo(Equal(oldPassword)) - utils.WaitForRabbitMQAuthenticationSuccess(env.backendNamespace, resourceName, newPassword) + e2eutils.WaitForRabbitMQAuthenticationSuccess(env.backendNamespace, resourceName, newPassword) }) }) - Context("Controller policy", Serial, func() { + Context("Settings ConfigMap policy", func() { var env rabbitMQSpecEnv BeforeEach(func() { - clearAllControllers() env = newRabbitMQSpecEnv() }) AfterEach(func() { env.cleanup() - clearAllControllers() }) - It("should delete stale RabbitMQ vhosts when singleton Controller policy enables deletion", func() { - controllerName := env.name("rabbitmq-vhost-cleanup-delete") + It("should delete stale RabbitMQ vhosts when settings ConfigMap policy enables deletion", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + keeperName := env.name("test-rabbitmq-vhost-keeper") staleName := env.name("test-rabbitmq-vhost-stale") keeperVhost := env.vhost("keeper") @@ -287,37 +288,37 @@ spec: {VHost: staleVhost, Configure: ".*", Write: ".*", Read: ".*"}, } - By("enabling stale RabbitMQ vhost deletion through singleton Controller settings") - err := createRabbitMQController(controllerName, &deletePolicy, nil) + By("enabling stale RabbitMQ vhost deletion through settings ConfigMap") + err := createRabbitMQController(nil, &deletePolicy, nil) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQ controller settings") DeferCleanup(func() { - deleteControllerResource(controllerName, namespace) + deleteControllerSettingsConfigMap(namespace) }) By("creating a keeper RabbitMQAccess resource") - err = utils.CreateRabbitMQAccessWithDirectConnection(keeperName, env.namespace, env.name("keeper-secret"), env.conn, keeperPermissions) + err = e2eutils.CreateRabbitMQAccessWithDirectConnection(keeperName, env.namespace, env.name("keeper-secret"), env.conn, keeperPermissions) Expect(err).NotTo(HaveOccurred(), "Failed to create keeper RabbitMQAccess resource") By("creating a second RabbitMQAccess resource that owns an orphanable vhost") - err = utils.CreateRabbitMQAccessWithDirectConnection(staleName, env.namespace, env.name("stale-secret"), env.conn, stalePermissions) + err = e2eutils.CreateRabbitMQAccessWithDirectConnection(staleName, env.namespace, env.name("stale-secret"), env.conn, stalePermissions) Expect(err).NotTo(HaveOccurred(), "Failed to create stale RabbitMQAccess resource") By("waiting for both RabbitMQ users and vhosts to exist") - utils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) - utils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, true) By("deleting the stale RabbitMQAccess resource") - err = utils.DeleteRabbitMQAccess(staleName, env.namespace) + err = e2eutils.DeleteRabbitMQAccess(staleName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete stale RabbitMQAccess resource") - By("verifying finalization deletes the stale user and its vhost while the keeper remains") - utils.WaitForResourceDeleted("rabbitmqaccess", staleName, env.namespace) - utils.WaitForRabbitMQUserState(env.backendNamespace, staleName, false) - utils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, false) - utils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) + By("verifying finalization retains the stale user but deletes its vhost while the keeper remains") + e2eutils.WaitForResourceDeleted("rabbitmqaccess", staleName, env.namespace) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, false) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) }) It("should retain orphaned RabbitMQ vhosts when stale vhost deletion is not enabled", func() { @@ -333,7 +334,7 @@ spec: } By("creating a keeper RabbitMQAccess resource") - err := utils.CreateRabbitMQAccessWithDirectConnection( + err := e2eutils.CreateRabbitMQAccessWithDirectConnection( keeperName, env.namespace, env.name("keeper-secret"), @@ -343,7 +344,7 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create keeper RabbitMQAccess resource") By("creating a second RabbitMQAccess resource whose vhost should be retained") - err = utils.CreateRabbitMQAccessWithDirectConnection( + err = e2eutils.CreateRabbitMQAccessWithDirectConnection( staleName, env.namespace, env.name("stale-secret"), @@ -353,25 +354,27 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create stale RabbitMQAccess resource") By("waiting for both RabbitMQ users and vhosts to exist") - utils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) - utils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, true) By("deleting the stale RabbitMQAccess resource") - err = utils.DeleteRabbitMQAccess(staleName, env.namespace) + err = e2eutils.DeleteRabbitMQAccess(staleName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete stale RabbitMQAccess resource") - By("verifying finalization deletes the stale user but retains its vhost by policy") - utils.WaitForResourceDeleted("rabbitmqaccess", staleName, env.namespace) - utils.WaitForRabbitMQUserState(env.backendNamespace, staleName, false) - utils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, true) - utils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) + By("verifying finalization retains the stale user and its vhost by default policy") + e2eutils.WaitForResourceDeleted("rabbitmqaccess", staleName, env.namespace) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, staleVhost, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) }) - It("should preserve excluded RabbitMQ vhosts when stale vhost deletion is enabled", func() { - controllerName := env.name("rabbitmq-vhost-cleanup-excluded") + It("should preserve excluded RabbitMQ vhosts when stale vhost deletion is enabled", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + keeperName := env.name("test-rabbitmq-vhost-excluded-keeper") staleName := env.name("test-rabbitmq-vhost-excluded-stale") keeperVhost := env.vhost("keeper-excluded") @@ -385,14 +388,14 @@ spec: } By("enabling stale RabbitMQ vhost deletion while excluding the protected vhost") - err := createRabbitMQController(controllerName, &deletePolicy, []string{protectedVhost}) + err := createRabbitMQController(nil, &deletePolicy, []string{protectedVhost}) Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQ controller settings") DeferCleanup(func() { - deleteControllerResource(controllerName, namespace) + deleteControllerSettingsConfigMap(namespace) }) By("creating a keeper RabbitMQAccess resource") - err = utils.CreateRabbitMQAccessWithDirectConnection( + err = e2eutils.CreateRabbitMQAccessWithDirectConnection( keeperName, env.namespace, env.name("keeper-secret"), @@ -402,7 +405,7 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create keeper RabbitMQAccess resource") By("creating a second RabbitMQAccess resource that uses the excluded vhost") - err = utils.CreateRabbitMQAccessWithDirectConnection( + err = e2eutils.CreateRabbitMQAccessWithDirectConnection( staleName, env.namespace, env.name("stale-secret"), @@ -412,24 +415,59 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create stale RabbitMQAccess resource") By("waiting for both RabbitMQ users and vhosts to exist") - utils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) - utils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, protectedVhost, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, protectedVhost, true) By("deleting the stale RabbitMQAccess resource") - err = utils.DeleteRabbitMQAccess(staleName, env.namespace) + err = e2eutils.DeleteRabbitMQAccess(staleName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete stale RabbitMQAccess resource") - By("verifying finalization deletes the stale user but retains the excluded vhost") - utils.WaitForResourceDeleted("rabbitmqaccess", staleName, env.namespace) - utils.WaitForRabbitMQUserState(env.backendNamespace, staleName, false) - utils.WaitForRabbitMQVhostState(env.backendNamespace, protectedVhost, true) - utils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) - utils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) + By("verifying finalization retains the stale user and the excluded vhost") + e2eutils.WaitForResourceDeleted("rabbitmqaccess", staleName, env.namespace) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, staleName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, protectedVhost, true) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, keeperName, true) + e2eutils.WaitForRabbitMQVhostState(env.backendNamespace, keeperVhost, true) }) - It("should deny cross-namespace existingSecret when no Controller resource exists", func() { + It("should delete stale RabbitMQ users when stale user deletion policy is Delete", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + + resourceName := env.name("test-rabbitmq-user-cleanup") + generatedSecret := env.name("test-rabbitmq-user-cleanup-secret") + staleUserDeletePolicy := accessv1.StaleUserDeletionPolicyDelete + permissions := []accessv1.RabbitMQPermissionSpec{ + {VHost: env.vhost("delete-user"), Configure: ".*", Write: ".*", Read: ".*"}, + } + + By("creating a settings ConfigMap with staleUserDeletionPolicy Delete") + err := createRabbitMQController(&staleUserDeletePolicy, nil, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQ controller settings") + DeferCleanup(func() { + deleteControllerSettingsConfigMap(namespace) + }) + + By("creating a RabbitMQAccess resource") + err = e2eutils.CreateRabbitMQAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, permissions) + Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource") + + By("waiting for the RabbitMQ user to exist") + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) + + By("deleting the RabbitMQAccess resource") + err = e2eutils.DeleteRabbitMQAccess(resourceName, env.namespace) + Expect(err).NotTo(HaveOccurred(), "Failed to delete RabbitMQAccess resource") + + By("verifying the RabbitMQ user is deleted by controller policy") + e2eutils.WaitForResourceDeleted("rabbitmqaccess", resourceName, env.namespace) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) + }) + + It("should deny cross-namespace existingSecret when no settings ConfigMap exists", func() { resourceName := env.name("test-rabbitmq-cross-namespace-no-controller") generatedSecretName := env.name("test-rabbitmq-cross-namespace-no-controller-secret") connectionSecretNamespace := createTestNamespace("rabbitmq-shared-no-controller") @@ -441,11 +479,11 @@ spec: } By("creating the connection secret in another namespace") - secretName, err := utils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a RabbitMQAccess that references the shared secret namespace") - err = utils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, permissions) + err = e2eutils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace RabbitMQAccess") By("verifying reconcile is denied with cross-namespace policy disabled") @@ -456,13 +494,15 @@ spec: }) By("verifying the requested RabbitMQ user was not created") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) }) - It("should deny cross-namespace existingSecret when singleton Controller setting is false", func() { + It("should deny cross-namespace existingSecret when settings ConfigMap setting is false", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + resourceName := env.name("test-rabbitmq-cross-namespace-controller-false") generatedSecretName := env.name("test-rabbitmq-cross-namespace-controller-false-secret") - controllerName := env.name("rabbitmq-cluster-settings-false") connectionSecretNamespace := createTestNamespace("rabbitmq-shared-controller-false") DeferCleanup(func() { deleteNamespace(connectionSecretNamespace) @@ -471,34 +511,36 @@ spec: {VHost: env.vhost("app"), Configure: ".*", Write: ".*", Read: ".*"}, } - By("creating a singleton Controller with existingSecretNamespace=false") - err := createControllerResource(controllerName, namespace, `existingSecretNamespace: false`) - Expect(err).NotTo(HaveOccurred(), "Failed to create singleton Controller with false policy") + By("creating settings ConfigMap with existingSecretNamespace=false") + err := createControllerSettingsConfigMap(namespace, `existingSecretNamespace: false`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap with false policy") DeferCleanup(func() { - deleteControllerResource(controllerName, namespace) + deleteControllerSettingsConfigMap(namespace) }) By("creating the connection secret in another namespace") - secretName, err := utils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a RabbitMQAccess that references the shared secret namespace") - err = utils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, permissions) + err = e2eutils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace RabbitMQAccess") - By("verifying reconcile is denied because singleton Controller policy is false") + By("verifying reconcile is denied because settings ConfigMap policy is false") waitForReadyCondition("rabbitmqaccess", namespacedName{name: resourceName, namespace: env.namespace}, readyConditionExpectation{ messageContains: "cross-namespace connection secret references are disabled", }) By("verifying the requested RabbitMQ user was not created") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) }) - It("should create a RabbitMQAccess resource using an existing connection secret from another namespace", func() { + It("should create a RabbitMQAccess resource using an existing connection secret from another namespace", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + resourceName := env.name("test-rabbitmq-cross-namespace") generatedSecretName := env.name("test-rabbitmq-cross-namespace-credentials") - controllerName := env.name("rabbitmq-cluster-settings") connectionSecretNamespace := createTestNamespace("rabbitmq-shared") DeferCleanup(func() { deleteNamespace(connectionSecretNamespace) @@ -507,19 +549,19 @@ spec: {VHost: env.vhost("app"), Configure: ".*", Write: ".*", Read: ".*"}, } - By("enabling cross-namespace references through the singleton Controller resource") - err := createControllerResource(controllerName, namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to enable cross-namespace references via Controller CR") + By("enabling cross-namespace references through operator settings ConfigMap") + err := createControllerSettingsConfigMap(namespace, `existingSecretNamespace: true`) + Expect(err).NotTo(HaveOccurred(), "Failed to enable cross-namespace references via settings ConfigMap") DeferCleanup(func() { - deleteControllerResource(controllerName, namespace) + deleteControllerSettingsConfigMap(namespace) }) By("creating the connection secret in the shared namespace") - secretName, err := utils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a RabbitMQAccess resource in the workload namespace that references the shared secret") - err = utils.CreateRabbitMQAccessFromSecretReference( + err = e2eutils.CreateRabbitMQAccessFromSecretReference( resourceName, env.namespace, generatedSecretName, @@ -530,17 +572,16 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create RabbitMQAccess resource with cross-namespace secret reference") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecretName, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecretName, "username") By("verifying the RabbitMQ user and permissions were created") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) - utils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, true) + e2eutils.WaitForRabbitMQPermissions(env.backendNamespace, resourceName, permissions) }) - It("should deny cross-namespace existingSecret when the singleton Controller is outside the operator namespace", func() { + It("should deny cross-namespace existingSecret when settings ConfigMap is outside the operator namespace", func() { resourceName := env.name("test-rabbitmq-wrong-controller-namespace") generatedSecretName := env.name("test-rabbitmq-wrong-controller-namespace-secret") - controllerName := env.name("rabbitmq-cluster-settings-wrong-namespace") connectionSecretNamespace := createTestNamespace("rabbitmq-shared-wrong-controller-namespace") DeferCleanup(func() { deleteNamespace(connectionSecretNamespace) @@ -549,120 +590,57 @@ spec: {VHost: env.vhost("app"), Configure: ".*", Write: ".*", Read: ".*"}, } - By("creating the singleton Controller in a workload namespace instead of the operator namespace") - err := createControllerResource(controllerName, env.namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to create singleton Controller outside the operator namespace") + By("creating settings ConfigMap in workload namespace instead of operator namespace") + err := createControllerSettingsConfigMap(env.namespace, `existingSecretNamespace: true`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap outside the operator namespace") DeferCleanup(func() { - deleteControllerResource(controllerName, env.namespace) + deleteControllerSettingsConfigMap(env.namespace) }) By("creating the connection secret in another namespace") - secretName, err := utils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a RabbitMQAccess that references the shared secret namespace") - err = utils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, permissions) + err = e2eutils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, permissions) Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace RabbitMQAccess") - By("verifying reconcile is denied because the Controller is not in the operator namespace") + By("verifying reconcile is denied because settings ConfigMap outside operator namespace is ignored") waitForReadyCondition("rabbitmqaccess", namespacedName{name: resourceName, namespace: env.namespace}, readyConditionExpectation{ status: "False", reason: "ConnectionError", - messageContains: `must be created in the operator namespace "access-operator-system"`, - }) - - By("verifying the misplaced Controller is marked not ready") - waitForReadyCondition("controller", namespacedName{name: controllerName, namespace: env.namespace}, readyConditionExpectation{ - status: "False", - reason: "InvalidControllerNamespace", - messageContains: `must be created in the operator namespace "access-operator-system"`, + messageContains: "cross-namespace connection secret references are disabled", }) By("verifying the requested RabbitMQ user was not created") - utils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) + e2eutils.WaitForRabbitMQUserState(env.backendNamespace, resourceName, false) }) - It("should fail when multiple Controller resources exist and emit warning events", func() { - resourceName := env.name("test-rabbitmq-multiple-controllers") - generatedSecretName := env.name("test-rabbitmq-multiple-controllers-secret") - controllerAName := env.name("rabbitmq-cluster-settings-a") - controllerBName := env.name("rabbitmq-cluster-settings-b") - connectionSecretNamespace := createTestNamespace("rabbitmq-shared-multiple-controller") - DeferCleanup(func() { - deleteNamespace(connectionSecretNamespace) - }) - permissions := []accessv1.RabbitMQPermissionSpec{ - {VHost: env.vhost("app"), Configure: ".*", Write: ".*", Read: ".*"}, - } - - By("creating two Controller resources to violate singleton policy") - err := createControllerResource(controllerAName, namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to create first Controller") - - err = createControllerResource(controllerBName, env.namespace, `existingSecretNamespace: true`) - Expect(err).NotTo(HaveOccurred(), "Failed to create second Controller") - - DeferCleanup(func() { - deleteControllerResource(controllerAName, namespace) - }) - DeferCleanup(func() { - deleteControllerResource(controllerBName, env.namespace) - }) - - By("creating the connection secret in another namespace") - secretName, err := utils.CreateRabbitMQConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) - Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") - - By("creating a RabbitMQAccess that references the shared secret namespace") - err = utils.CreateRabbitMQAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, permissions) - Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace RabbitMQAccess") - - By("verifying RabbitMQAccess fails with multiple-controller error") - waitForReadyCondition("rabbitmqaccess", namespacedName{name: resourceName, namespace: env.namespace}, readyConditionExpectation{ - reason: "ConnectionError", - messageContains: "multiple Controller resources found", - }) - - By("verifying both Controller resources are marked Ready=False with MultipleControllersFound") - controllerResources := []namespacedName{ - {name: controllerAName, namespace: namespace}, - {name: controllerBName, namespace: env.namespace}, - } - waitForControllerResourcesReadyCondition(controllerResources, readyConditionExpectation{ - status: "False", - reason: "MultipleControllersFound", - }) - - By("verifying warning events are emitted for both Controller resources") - for _, resource := range controllerResources { - waitForResourceWarningEvent(resource, "Controller", "MultipleControllersFound") - } - - By("verifying warning event is emitted on controller-manager Deployment") - waitForResourceWarningEvent(namespacedName{name: managerDeploymentName, namespace: namespace}, "Deployment", "MultipleControllersFound") - }) }) }) func createRabbitMQController( - name string, - policy *accessv1.StaleVhostDeletionPolicy, + staleUserPolicy *accessv1.StaleUserDeletionPolicy, + staleVhostPolicy *accessv1.StaleVhostDeletionPolicy, excludedVhosts []string, ) error { var settings strings.Builder - if policy != nil || len(excludedVhosts) > 0 { + if staleUserPolicy != nil || staleVhostPolicy != nil || len(excludedVhosts) > 0 { settings.WriteString("rabbitmq:\n") + if staleUserPolicy != nil { + settings.WriteString(fmt.Sprintf(" staleUserDeletionPolicy: %s\n", *staleUserPolicy)) + } if len(excludedVhosts) > 0 { settings.WriteString(" excludedVhosts:\n") for _, vhost := range excludedVhosts { settings.WriteString(fmt.Sprintf(" - %s\n", vhost)) } } - if policy != nil { - settings.WriteString(fmt.Sprintf(" staleVhostDeletionPolicy: %s\n", *policy)) + if staleVhostPolicy != nil { + settings.WriteString(fmt.Sprintf(" staleVhostDeletionPolicy: %s\n", *staleVhostPolicy)) } } - return createControllerResource(name, namespace, settings.String()) + return createControllerSettingsConfigMap(namespace, settings.String()) } diff --git a/test/e2e/redis_controller_policy_e2e_test.go b/test/e2e/redis_controller_policy_e2e_test.go index af3ec30..3c4f75e 100644 --- a/test/e2e/redis_controller_policy_e2e_test.go +++ b/test/e2e/redis_controller_policy_e2e_test.go @@ -7,27 +7,25 @@ import ( "fmt" "strings" + accessv1 "github.com/delta10/access-operator/api/v1" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - - "github.com/delta10/access-operator/test/utils" ) var _ = Describe("Redis", func() { - Context("Controller policy", Serial, func() { + Context("Settings ConfigMap policy", func() { var env redisSpecEnv BeforeEach(func() { - clearAllControllers() env = newRedisSpecEnv() }) AfterEach(func() { env.cleanup() - clearAllControllers() }) - It("should deny cross-namespace existingSecret when no Controller resource exists", func() { + It("should deny cross-namespace existingSecret when no settings ConfigMap exists", func() { resourceName := env.name("test-redis-cross-namespace-no-controller") generatedSecretName := env.name("test-redis-cross-namespace-no-controller-secret") connectionSecretNamespace := createTestNamespace("redis-shared-no-controller") @@ -37,11 +35,11 @@ var _ = Describe("Redis", func() { aclRules := []string{"~shared:*", "+get"} By("creating the connection secret in another namespace") - secretName, err := utils.CreateRedisConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateRedisConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a RedisAccess that references the shared secret namespace") - err = utils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, aclRules) + err = e2eutils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace RedisAccess") By("verifying reconcile is denied with cross-namespace policy disabled") @@ -52,75 +50,81 @@ var _ = Describe("Redis", func() { }) By("verifying the requested Redis ACL user was not created") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, false) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, false) }) - It("should deny cross-namespace existingSecret when singleton Controller setting is false", func() { + It("should deny cross-namespace existingSecret when settings ConfigMap setting is false", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + resourceName := env.name("test-redis-cross-namespace-controller-false") generatedSecretName := env.name("test-redis-cross-namespace-controller-false-secret") - controllerName := env.name("redis-cluster-settings-false") connectionSecretNamespace := createTestNamespace("redis-shared-controller-false") DeferCleanup(func() { deleteNamespace(connectionSecretNamespace) }) aclRules := []string{"~shared:*", "+get"} - By("creating a singleton Controller with existingSecretNamespace=false") - err := createControllerResource(controllerName, namespace, `existingSecretNamespace: false`) - Expect(err).NotTo(HaveOccurred(), "Failed to create singleton Controller with false policy") + By("creating settings ConfigMap with existingSecretNamespace=false") + err := createControllerSettingsConfigMap(namespace, `existingSecretNamespace: false`) + Expect(err).NotTo(HaveOccurred(), "Failed to create settings ConfigMap with false policy") By("creating the connection secret in another namespace") - secretName, err := utils.CreateRedisConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateRedisConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a RedisAccess that references the shared secret namespace") - err = utils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, aclRules) + err = e2eutils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create cross-namespace RedisAccess") - By("verifying reconcile is denied because singleton Controller policy is false") + By("verifying reconcile is denied because settings ConfigMap policy is false") waitForReadyCondition("redisaccess", namespacedName{name: resourceName, namespace: env.namespace}, readyConditionExpectation{ messageContains: "cross-namespace connection secret references are disabled", }) By("verifying the requested Redis ACL user was not created") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, false) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, false) }) - It("should create a RedisAccess resource using an existing connection secret from another namespace", func() { + It("should create a RedisAccess resource using an existing connection secret from another namespace", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + resourceName := env.name("test-redis-cross-namespace") generatedSecretName := env.name("test-redis-cross-namespace-credentials") - controllerName := env.name("redis-cluster-settings") connectionSecretNamespace := createTestNamespace("redis-shared") DeferCleanup(func() { deleteNamespace(connectionSecretNamespace) }) aclRules := []string{"~cross:*", "+get", "+set"} - By("enabling cross-namespace references through the singleton Controller resource") - err := createControllerResource(controllerName, namespace, `existingSecretNamespace: true`) + By("enabling cross-namespace references through operator settings ConfigMap") + err := createControllerSettingsConfigMap(namespace, `existingSecretNamespace: true`) Expect(err).NotTo(HaveOccurred(), "Failed to enable cross-namespace references via Controller CR") By("creating the connection secret in the shared namespace") - secretName, err := utils.CreateRedisConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) + secretName, err := e2eutils.CreateRedisConnectionDetailsViaSecret(connectionSecretNamespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create connection secret in shared namespace") By("creating a RedisAccess resource in the workload namespace that references the shared secret") - err = utils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, aclRules) + err = e2eutils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecretName, secretName, &connectionSecretNamespace, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource with cross-namespace secret reference") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecretName, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecretName, "username") By("verifying the Redis ACL user and rules were created") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) }) - It("should preserve excluded Redis ACL users from singleton Controller settings", func() { + It("should preserve excluded Redis ACL users from settings ConfigMap", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + excludedUsername := env.name("excluded-keeper") managedUsername := env.name("test-redis-managed-user") generatedSecret := env.name("test-redis-managed-secret") - controllerName := env.name("redis-excluded-users") managedACLRules := []string{"~managed:*", "+get"} settingsYAML := strings.Join([]string{ @@ -129,12 +133,12 @@ var _ = Describe("Redis", func() { fmt.Sprintf(" - %s", excludedUsername), }, "\n") - By("creating a singleton Controller that excludes the unmanaged Redis user") - err := createControllerResource(controllerName, namespace, settingsYAML) + By("creating settings ConfigMap that excludes the unmanaged Redis user") + err := createControllerSettingsConfigMap(namespace, settingsYAML) Expect(err).NotTo(HaveOccurred(), "Failed to create Redis exclusion Controller") By("creating an unmanaged Redis ACL user that should be preserved") - _, err = utils.RunRedisCLI( + _, err = e2eutils.RunRedisCLI( env.backendNamespace, env.conn, "ACL", "SETUSER", excludedUsername, @@ -143,14 +147,70 @@ var _ = Describe("Redis", func() { Expect(err).NotTo(HaveOccurred(), "Failed to create excluded Redis ACL user") By("creating a RedisAccess resource to trigger reconciliation") - err = utils.CreateRedisAccessWithDirectConnection(managedUsername, env.namespace, generatedSecret, env.conn, managedACLRules) + err = e2eutils.CreateRedisAccessWithDirectConnection(managedUsername, env.namespace, generatedSecret, env.conn, managedACLRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource") By("waiting for the managed Redis ACL user to exist") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, managedUsername, true) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, managedUsername, true) By("verifying the excluded unmanaged Redis user is not removed") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, excludedUsername, true) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, excludedUsername, true) + }) + + It("should retain stale Redis users when stale user deletion policy is Restrict", func() { + resourceName := env.name("test-redis-restrict-retain") + generatedSecret := env.name("test-redis-restrict-retain-secret") + aclRules := []string{"~retain:*", "+get"} + + By("creating a RedisAccess resource") + err := e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) + Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource") + + By("waiting for the Redis ACL user to exist") + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + + By("deleting the RedisAccess resource") + err = e2eutils.DeleteRedisAccess(resourceName, env.namespace) + Expect(err).NotTo(HaveOccurred(), "Failed to delete RedisAccess resource") + + By("verifying the Redis user is retained by the default Restrict policy") + e2eutils.WaitForResourceDeleted("redisaccess", resourceName, env.namespace) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) + }) + + It("should delete stale Redis users when stale user deletion policy is Delete", Serial, func() { + clearAllControllerSettingsConfigMaps() + DeferCleanup(clearAllControllerSettingsConfigMaps) + + resourceName := env.name("test-redis-delete-stale-user") + generatedSecret := env.name("test-redis-delete-stale-user-secret") + deletePolicy := accessv1.StaleUserDeletionPolicyDelete + aclRules := []string{"~delete:*", "+get"} + + By("creating a settings ConfigMap with staleUserDeletionPolicy Delete") + err := createControllerSettingsConfigMap(namespace, fmt.Sprintf(`redis: + staleUserDeletionPolicy: %s`, deletePolicy)) + Expect(err).NotTo(HaveOccurred(), "Failed to create Redis controller settings") + DeferCleanup(func() { + deleteControllerSettingsConfigMap(namespace) + }) + + By("creating a RedisAccess resource") + err = e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) + Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource") + + By("waiting for the Redis ACL user to exist") + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + + By("deleting the RedisAccess resource") + err = e2eutils.DeleteRedisAccess(resourceName, env.namespace) + Expect(err).NotTo(HaveOccurred(), "Failed to delete RedisAccess resource") + + By("verifying the Redis user is deleted by controller policy") + e2eutils.WaitForResourceDeleted("redisaccess", resourceName, env.namespace) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, false) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) }) }) }) diff --git a/test/e2e/redis_e2e_test.go b/test/e2e/redis_e2e_test.go index ab06bf8..a799456 100644 --- a/test/e2e/redis_e2e_test.go +++ b/test/e2e/redis_e2e_test.go @@ -23,11 +23,11 @@ import ( "fmt" "os/exec" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/delta10/access-operator/internal/controller" - "github.com/delta10/access-operator/test/utils" ) var _ = Describe("Redis", func() { @@ -61,7 +61,7 @@ spec: - "+get" `, resourceName, env.namespace, generatedSecretName, resourceName) - err := utils.ApplyManifest(invalidResource) + err := e2eutils.ApplyManifest(invalidResource) Expect(err).NotTo(HaveOccurred(), "Failed to create invalid RedisAccess resource") By("verifying the RedisAccess status reports the reconcile failure") @@ -80,19 +80,19 @@ spec: aclRules := []string{"~cache:*", "+@read", "+@write"} By("creating a RedisAccess resource") - err := utils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) + err := e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource with connection details") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the Redis ACL user was created") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) By("verifying the generated credentials can authenticate") - password := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") - utils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, password) + password := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + e2eutils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, password) }) It("should create a RedisAccess resource with direct host/port and secret-referenced credentials", func() { @@ -101,19 +101,19 @@ spec: aclRules := []string{"~orders:*", "+get", "+set"} By("creating a secret with the connection details") - secretName, err := utils.CreateRedisConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateRedisConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create Redis connection secret") By("creating a RedisAccess resource referencing the username/password secret and providing host/port directly") - err = utils.CreateRedisAccessWithConnectionSecretRef(resourceName, env.namespace, generatedSecret, env.conn, secretName, aclRules) + err = e2eutils.CreateRedisAccessWithConnectionSecretRef(resourceName, env.namespace, generatedSecret, env.conn, secretName, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource with secret references") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the Redis ACL user and rules were created") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) }) It("should create a RedisAccess resource using an existing connection secret in the same namespace", func() { @@ -122,42 +122,42 @@ spec: aclRules := []string{"~shared:*", "+get"} By("creating a secret with the connection details") - secretName, err := utils.CreateRedisConnectionDetailsViaSecret(env.namespace, env.conn) + secretName, err := e2eutils.CreateRedisConnectionDetailsViaSecret(env.namespace, env.conn) Expect(err).NotTo(HaveOccurred(), "Failed to create Redis connection secret") By("creating a RedisAccess resource referencing the connection secret") - err = utils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecret, secretName, nil, aclRules) + err = e2eutils.CreateRedisAccessFromSecretReference(resourceName, env.namespace, generatedSecret, secretName, nil, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource with existingSecret") By("waiting for the generated secret to be created") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") By("verifying the Redis ACL user and rules were created") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) }) - It("should delete the Redis ACL user and generated secret when the RedisAccess resource is deleted", func() { + It("should retain the Redis ACL user and delete the generated secret when the RedisAccess resource is deleted by default", func() { resourceName := env.name("test-redis-deletion") generatedSecret := env.name("test-redis-deletion-secret") aclRules := []string{"~delete:*", "+get", "+set"} By("creating a RedisAccess resource") - err := utils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) + err := e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource") By("waiting for the generated secret and Redis user to exist") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) By("deleting the RedisAccess resource") - err = utils.DeleteRedisAccess(resourceName, env.namespace) + err = e2eutils.DeleteRedisAccess(resourceName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete RedisAccess resource") - By("verifying finalization removed the RedisAccess, Redis user, and generated secret") - utils.WaitForResourceDeleted("redisaccess", resourceName, env.namespace) - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, false) - utils.WaitForSecretDeleted(env.namespace, generatedSecret) + By("verifying finalization removed the RedisAccess, retained the Redis user, and deleted the generated secret") + e2eutils.WaitForResourceDeleted("redisaccess", resourceName, env.namespace) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForSecretDeleted(env.namespace, generatedSecret) }) It("should reconcile permissions when they're changed in the config", func() { @@ -166,20 +166,20 @@ spec: aclRules := []string{"~delete:*", "+get", "+set"} By("creating a RedisAccess resource") - err := utils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) + err := e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource") By("verifying the Redis ACL user was created") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, resourceName, true) + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, aclRules) By("Changing the config to have different ACL rules") newAclRules := []string{"~delete:*", "+get"} - err = utils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, newAclRules) + err = e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, newAclRules) Expect(err).NotTo(HaveOccurred(), "Failed to update RedisAccess ACL rules") By("verifying the Redis ACL rules are updated") - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, newAclRules) + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, newAclRules) }) @@ -189,27 +189,27 @@ spec: desiredRules := []string{"~drift:*", "+get", "+set"} By("creating a RedisAccess resource") - err := utils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, desiredRules) + err := e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, desiredRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource") By("waiting for the generated secret and initial Redis ACL rules") - utils.WaitForSecretField(env.namespace, generatedSecret, "username") - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, desiredRules) + e2eutils.WaitForSecretField(env.namespace, generatedSecret, "username") + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, desiredRules) By("manually drifting the Redis ACL user") - _, err = utils.RunRedisCLI(env.backendNamespace, env.conn, + _, err = e2eutils.RunRedisCLI(env.backendNamespace, env.conn, "ACL", "SETUSER", resourceName, "reset", "on", ">wrong-password", "~drifted:*", "+get", ) Expect(err).NotTo(HaveOccurred(), "Failed to drift Redis ACL user") - err = utils.TriggerReconciliation("redisaccess", resourceName, env.namespace) + err = e2eutils.TriggerReconciliation("redisaccess", resourceName, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to trigger Redis reconciliation") By("verifying that the controller restores the desired Redis ACL rules and password") - utils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, desiredRules) - password := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") - utils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, password) + e2eutils.WaitForRedisACLRules(env.backendNamespace, env.conn, resourceName, desiredRules) + password := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + e2eutils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, password) }) It("should update the Redis user's password when the generated secret is rotated by deletion", func() { @@ -218,22 +218,22 @@ spec: aclRules := []string{"~rotation:*", "+get"} By("creating a RedisAccess resource") - err := utils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) + err := e2eutils.CreateRedisAccessWithDirectConnection(resourceName, env.namespace, generatedSecret, env.conn, aclRules) Expect(err).NotTo(HaveOccurred(), "Failed to create RedisAccess resource") By("waiting for the initial generated credentials") - oldPassword := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") - utils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, oldPassword) + oldPassword := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + e2eutils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, oldPassword) By("deleting the generated secret to trigger password rotation") cmd := exec.Command("kubectl", "delete", "secret", generatedSecret, "-n", env.namespace) - _, err = utils.Run(cmd) + _, err = e2eutils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to delete generated secret") By("verifying that the Redis user's password is rotated and the new password authenticates") - newPassword := utils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") + newPassword := e2eutils.WaitForDecodedSecretField(env.namespace, generatedSecret, "password") Expect(newPassword).NotTo(Equal(oldPassword)) - utils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, newPassword) + e2eutils.WaitForRedisAuthenticationSuccess(env.backendNamespace, resourceName, newPassword) }) It("should keep Redis ACL users that are still used as connection usernames by another RedisAccess", func() { @@ -243,16 +243,16 @@ spec: dependentSecret := env.name("test-redis-dependent-secret") By("creating the RedisAccess that owns the connection username") - err := utils.CreateRedisAccessWithDirectConnection(protectedUser, env.namespace, protectedSecret, env.conn, []string{"~*", "+@all"}) + err := e2eutils.CreateRedisAccessWithDirectConnection(protectedUser, env.namespace, protectedSecret, env.conn, []string{"~*", "+@all"}) Expect(err).NotTo(HaveOccurred(), "Failed to create protected RedisAccess") By("waiting for the protected user credentials to exist") - utils.WaitForSecretField(env.namespace, protectedSecret, "username") - protectedPassword := utils.WaitForDecodedSecretField(env.namespace, protectedSecret, "password") - utils.WaitForRedisAuthenticationSuccess(env.backendNamespace, protectedUser, protectedPassword) + e2eutils.WaitForSecretField(env.namespace, protectedSecret, "username") + protectedPassword := e2eutils.WaitForDecodedSecretField(env.namespace, protectedSecret, "password") + e2eutils.WaitForRedisAuthenticationSuccess(env.backendNamespace, protectedUser, protectedPassword) By("creating another RedisAccess that uses the protected user for its Redis connection") - err = utils.CreateRedisAccessWithConnectionSecretRef(dependentResource, env.namespace, dependentSecret, controller.ConnectionDetails{ + err = e2eutils.CreateRedisAccessWithConnectionSecretRef(dependentResource, env.namespace, dependentSecret, controller.ConnectionDetails{ SharedConnectionDetails: controller.SharedConnectionDetails{ Host: env.conn.Host, Port: env.conn.Port, @@ -261,17 +261,17 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to create dependent RedisAccess") By("waiting for the dependent resource to reconcile") - utils.WaitForSecretField(env.namespace, dependentSecret, "username") - utils.WaitForRedisUserState(env.backendNamespace, env.conn, dependentResource, true) + e2eutils.WaitForSecretField(env.namespace, dependentSecret, "username") + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, dependentResource, true) By("deleting the RedisAccess that owns the connection username") - err = utils.DeleteRedisAccess(protectedUser, env.namespace) + err = e2eutils.DeleteRedisAccess(protectedUser, env.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete protected RedisAccess") By("verifying the protected Redis user is retained because another RedisAccess still uses it for connections") - utils.WaitForResourceDeleted("redisaccess", protectedUser, env.namespace) - utils.WaitForRedisUserState(env.backendNamespace, env.conn, protectedUser, true) - utils.WaitForSecretDeleted(env.namespace, protectedSecret) + e2eutils.WaitForResourceDeleted("redisaccess", protectedUser, env.namespace) + e2eutils.WaitForRedisUserState(env.backendNamespace, env.conn, protectedUser, true) + e2eutils.WaitForSecretDeleted(env.namespace, protectedSecret) }) }) }) diff --git a/test/e2e/test_helpers_test.go b/test/e2e/test_helpers_test.go index 9fcbc73..5e1b908 100644 --- a/test/e2e/test_helpers_test.go +++ b/test/e2e/test_helpers_test.go @@ -11,7 +11,8 @@ import ( . "github.com/onsi/gomega" - "github.com/delta10/access-operator/test/utils" + operatorcontroller "github.com/delta10/access-operator/internal/controller" + e2eutils "github.com/delta10/access-operator/test/e2e/utils" ) type namespacedName struct { @@ -62,15 +63,122 @@ func getReadyConditionField(resourceType string, resource namespacedName, field "-o", fmt.Sprintf("jsonpath={.status.conditions[?(@.type=='Ready')].%s}", field), ) - output, err := utils.Run(cmd) + output, err := e2eutils.Run(cmd) return strings.TrimSpace(output), err } +func forceDeleteAccessResource(resourceType string, resource namespacedName) { + cmd := exec.Command( + "kubectl", + "delete", + resourceType, + resource.name, + "-n", + resource.namespace, + "--ignore-not-found", + "--wait=false", + ) + _, _ = e2eutils.Run(cmd) + + Eventually(func(g Gomega) { + cmd = exec.Command( + "kubectl", + "patch", + resourceType, + resource.name, + "-n", + resource.namespace, + "--type=merge", + "-p", + `{"metadata":{"finalizers":null}}`, + ) + _, _ = e2eutils.Run(cmd) + + cmd := exec.Command( + "kubectl", + "get", + resourceType, + resource.name, + "-n", + resource.namespace, + "-o", + "name", + "--ignore-not-found", + ) + output, err := e2eutils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred(), "Failed to check forced deletion for %s %s/%s", resourceType, resource.namespace, resource.name) + g.Expect(strings.TrimSpace(output)).To(BeEmpty()) + }, 30*time.Second, time.Second).Should(Succeed()) +} + +func forceDeleteAccessResourcesInNamespace(namespace string) { + for _, resourceType := range []string{"postgresaccess", "rabbitmqaccess", "redisaccess"} { + Eventually(func(g Gomega) { + cmd := exec.Command( + "kubectl", + "get", + resourceType, + "-n", + namespace, + "-o", + "name", + "--ignore-not-found", + ) + output, err := e2eutils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred(), "Failed to list access resources for cleanup for %s in namespace %s", resourceType, namespace) + + resourceNames := strings.Fields(output) + if len(resourceNames) == 0 { + return + } + + for _, resourceName := range resourceNames { + cmd = exec.Command( + "kubectl", + "delete", + resourceName, + "-n", + namespace, + "--ignore-not-found", + "--wait=false", + ) + _, _ = e2eutils.Run(cmd) + + cmd = exec.Command( + "kubectl", + "patch", + resourceName, + "-n", + namespace, + "--type=merge", + "-p", + `{"metadata":{"finalizers":null}}`, + ) + _, _ = e2eutils.Run(cmd) + } + + cmd = exec.Command( + "kubectl", + "get", + resourceType, + "-n", + namespace, + "-o", + "name", + "--ignore-not-found", + ) + output, err = e2eutils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred(), "Failed to check access resource cleanup for %s in namespace %s", resourceType, namespace) + g.Expect(strings.TrimSpace(output)).To(BeEmpty()) + }, 30*time.Second, time.Second).Should(Succeed()) + } +} + func waitForControllerLogsContain(substrings ...string) { Eventually(func(g Gomega) { controllerPodName = ensureControllerPodName() cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace, "--since=10m") - output, err := utils.Run(cmd) + output, err := e2eutils.Run(cmd) g.Expect(err).NotTo(HaveOccurred(), "Failed to read controller logs") for _, substring := range substrings { g.Expect(output).To(ContainSubstring(substring)) @@ -78,32 +186,51 @@ func waitForControllerLogsContain(substrings ...string) { }, 2*time.Minute, 5*time.Second).Should(Succeed()) } -func createControllerResource(name, namespace, settingsYAML string) error { +func createControllerSettingsConfigMap(namespace, settingsYAML string) error { settingsYAML = strings.TrimSpace(settingsYAML) if settingsYAML == "" { return fmt.Errorf("controller settings YAML cannot be empty") } - manifest := fmt.Sprintf(`apiVersion: access.k8s.delta10.nl/v1 -kind: Controller + manifest := fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap metadata: name: %s namespace: %s -spec: - settings: +data: + %s: | %s -`, name, namespace, indentYAMLBlock(settingsYAML, " ")) +`, operatorcontroller.ControllerSettingsConfigMapName, namespace, operatorcontroller.ControllerSettingsConfigMapKey, indentYAMLBlock(settingsYAML, " ")) - return utils.ApplyManifest(manifest) + return e2eutils.ApplyManifest(manifest) } -func deleteControllerResource(name, namespace string) { - cmd := exec.Command("kubectl", "delete", "controller", name, "-n", namespace, "--ignore-not-found", "--wait=false") - _, _ = utils.Run(cmd) +func deleteControllerSettingsConfigMap(namespace string) { + cmd := exec.Command( + "kubectl", + "delete", + "configmap", + operatorcontroller.ControllerSettingsConfigMapName, + "-n", + namespace, + "--ignore-not-found", + "--wait=false", + ) + _, _ = e2eutils.Run(cmd) Eventually(func(g Gomega) { - cmd := exec.Command("kubectl", "get", "controller", name, "-n", namespace, "-o", "name", "--ignore-not-found") - output, err := utils.Run(cmd) + cmd := exec.Command( + "kubectl", + "get", + "configmap", + "-n", + namespace, + operatorcontroller.ControllerSettingsConfigMapName, + "-o", + "name", + "--ignore-not-found", + ) + output, err := e2eutils.Run(cmd) g.Expect(err).NotTo(HaveOccurred()) g.Expect(strings.TrimSpace(output)).To(BeEmpty()) }, 30*time.Second, time.Second).Should(Succeed()) @@ -121,36 +248,17 @@ func waitForResourceWarningEvent(resource namespacedName, kind, reason string) { fmt.Sprintf("involvedObject.kind=%s,involvedObject.name=%s,reason=%s", kind, resource.name, reason), "--no-headers", ) - output, err := utils.Run(cmd) + output, err := e2eutils.Run(cmd) g.Expect(err).NotTo(HaveOccurred()) g.Expect(strings.TrimSpace(output)).NotTo(BeEmpty()) }, 2*time.Minute, 5*time.Second).Should(Succeed()) } -func waitForControllerResourcesReadyCondition(resources []namespacedName, expectation readyConditionExpectation) { - Eventually(func(g Gomega) { - for _, resource := range resources { - if expectation.status != "" { - status, err := getReadyConditionField("controller", resource, "status") - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(status).To(Equal(expectation.status)) - } - - if expectation.reason != "" { - reason, err := getReadyConditionField("controller", resource, "reason") - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(reason).To(Equal(expectation.reason)) - } - } - }, 2*time.Minute, 5*time.Second).Should(Succeed()) -} - -func waitForNoControllers() { +func waitForNoControllerSettingsConfigMaps() { Eventually(func(g Gomega) { - cmd := exec.Command("kubectl", "get", "controller", "-A", "-o", "name", "--ignore-not-found") - output, err := utils.Run(cmd) + configMaps, err := listControllerSettingsConfigMaps() g.Expect(err).NotTo(HaveOccurred()) - g.Expect(strings.TrimSpace(output)).To(BeEmpty()) + g.Expect(configMaps).To(BeEmpty()) }, 30*time.Second, time.Second).Should(Succeed()) } @@ -161,3 +269,41 @@ func indentYAMLBlock(block, indent string) string { } return strings.Join(lines, "\n") } + +func listControllerSettingsConfigMaps() ([]namespacedName, error) { + cmd := exec.Command( + "kubectl", + "get", + "configmap", + "-A", + "--field-selector", + fmt.Sprintf("metadata.name=%s", operatorcontroller.ControllerSettingsConfigMapName), + "-o", + `jsonpath={range .items[*]}{.metadata.namespace}{"\t"}{.metadata.name}{"\n"}{end}`, + ) + output, err := e2eutils.Run(cmd) + if err != nil { + return nil, err + } + + trimmedOutput := strings.TrimSpace(output) + if trimmedOutput == "" { + return nil, nil + } + + lines := strings.Split(trimmedOutput, "\n") + configMaps := make([]namespacedName, 0, len(lines)) + for _, line := range lines { + fields := strings.SplitN(strings.TrimSpace(line), "\t", 2) + if len(fields) != 2 { + return nil, fmt.Errorf("unexpected configmap listing output %q", line) + } + + configMaps = append(configMaps, namespacedName{ + namespace: fields[0], + name: fields[1], + }) + } + + return configMaps, nil +} diff --git a/test/utils/e2e.go b/test/e2e/utils/e2e.go similarity index 87% rename from test/utils/e2e.go rename to test/e2e/utils/e2e.go index 38252c3..519e79e 100644 --- a/test/utils/e2e.go +++ b/test/e2e/utils/e2e.go @@ -106,6 +106,40 @@ func WaitForCRDsEstablished(crdNames ...string) error { return err } +// WaitForAPIResources waits until the given plural resources are discoverable in the API group. +func WaitForAPIResources(apiGroup string, resourceNames ...string) error { + if len(resourceNames) == 0 { + return nil + } + + deadline := time.Now().Add(2 * time.Minute) + for { + cmd := exec.Command("kubectl", "api-resources", "--api-group", apiGroup, "-o", "name") + output, err := Run(cmd) + if err == nil { + allPresent := true + for _, resourceName := range resourceNames { + if !strings.Contains(output, resourceName) { + allPresent = false + break + } + } + if allPresent { + return nil + } + } + + if time.Now().After(deadline) { + if err != nil { + return err + } + return fmt.Errorf("timed out waiting for api resources in group %q: %v", apiGroup, resourceNames) + } + + time.Sleep(2 * time.Second) + } +} + // WaitForSecretField waits until a secret data field is present and returns its value. func WaitForSecretField(namespace, secretName, field string) string { var output string diff --git a/test/utils/e2e_postgres.go b/test/e2e/utils/e2e_postgres.go similarity index 96% rename from test/utils/e2e_postgres.go rename to test/e2e/utils/e2e_postgres.go index 4e9ef8b..bce5151 100644 --- a/test/utils/e2e_postgres.go +++ b/test/e2e/utils/e2e_postgres.go @@ -547,7 +547,6 @@ func CreateResourceFromSecretReference( namespace, generatedSecretName, connSecret string, - cleanupPolicy *accessv1.CleanupPolicy, grants accessv1.GrantSpec, ) error { return createResourceFromSecretReference( @@ -556,7 +555,6 @@ func CreateResourceFromSecretReference( generatedSecretName, connSecret, nil, - cleanupPolicy, grants, ) } @@ -568,7 +566,6 @@ func CreateResourceFromSecretReferenceWithNamespace( generatedSecretName, connSecret, connSecretNamespace string, - cleanupPolicy *accessv1.CleanupPolicy, grants accessv1.GrantSpec, ) error { return createResourceFromSecretReference( @@ -577,7 +574,6 @@ func CreateResourceFromSecretReferenceWithNamespace( generatedSecretName, connSecret, &connSecretNamespace, - cleanupPolicy, grants, ) } @@ -588,14 +584,8 @@ func createResourceFromSecretReference( generatedSecretName, connSecret string, connSecretNamespace *string, - cleanupPolicy *accessv1.CleanupPolicy, grants accessv1.GrantSpec, ) error { - cleanupPolicyYAML := "" - if cleanupPolicy != nil && *cleanupPolicy != "" { - cleanupPolicyYAML = fmt.Sprintf(" cleanupPolicy: %s\n", *cleanupPolicy) - } - existingSecretNamespaceYAML := "" if connSecretNamespace != nil && strings.TrimSpace(*connSecretNamespace) != "" { existingSecretNamespaceYAML = fmt.Sprintf(" existingSecretNamespace: %s\n", strings.TrimSpace(*connSecretNamespace)) @@ -611,13 +601,12 @@ spec: username: %s connection: existingSecret: %s -%s %s grants: - database: %s privileges: %s -`, username, namespace, generatedSecretName, username, connSecret, existingSecretNamespaceYAML, cleanupPolicyYAML, grants.Database, formatStringListYAML(grants.Privileges, " ")) +`, username, namespace, generatedSecretName, username, connSecret, existingSecretNamespaceYAML, grants.Database, formatStringListYAML(grants.Privileges, " ")) return ApplyManifest(pgAccessYAML) } @@ -629,26 +618,24 @@ func WaitForDatabaseUserState( username string, shouldExist bool, ) { - expected := "f" - if shouldExist { - expected = "t" - } - Eventually(func(g Gomega) { - output, err := RunPostgresQuery( - namespace, - connection, - fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = '%s');", username), - ) - g.Expect(err).NotTo(HaveOccurred(), "Failed to check if user exists") - g.Expect(output).To(Equal(expected)) - allUsersOutput, err := RunPostgresQuery( namespace, connection, "SELECT rolname FROM pg_roles;", ) g.Expect(err).NotTo(HaveOccurred(), "Failed to list all users") + + users := strings.Fields(allUsersOutput) + userExists := false + for _, user := range users { + if user == username { + userExists = true + break + } + } + + g.Expect(userExists).To(Equal(shouldExist), "Expected role %q existence to be %t. Current database users: %s", username, shouldExist, allUsersOutput) fmt.Printf("Current database users: %s\n", allUsersOutput) }, 2*time.Minute, 5*time.Second).Should(Succeed()) } @@ -679,6 +666,19 @@ func WaitForTableOwner(namespace string, connection controller.ConnectionDetails }, 2*time.Minute, 5*time.Second).Should(Succeed()) } +// WaitForTableMissing waits until the specified table no longer exists. +func WaitForTableMissing(namespace string, connection controller.ConnectionDetails, table string) { + Eventually(func(g Gomega) { + output, err := RunPostgresQuery( + namespace, + connection, + fmt.Sprintf("SELECT to_regclass('public.%q') IS NULL;", table), + ) + g.Expect(err).NotTo(HaveOccurred(), "Failed to query table existence") + g.Expect(output).To(Equal("t")) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) +} + // WaitForAuthenticationSuccess waits until auth succeeds for the provided credentials. func WaitForAuthenticationSuccess( namespace string, diff --git a/test/utils/e2e_rabbitmq.go b/test/e2e/utils/e2e_rabbitmq.go similarity index 100% rename from test/utils/e2e_rabbitmq.go rename to test/e2e/utils/e2e_rabbitmq.go diff --git a/test/utils/e2e_redis.go b/test/e2e/utils/e2e_redis.go similarity index 100% rename from test/utils/e2e_redis.go rename to test/e2e/utils/e2e_redis.go diff --git a/test/utils/utils.go b/test/e2e/utils/utils.go similarity index 100% rename from test/utils/utils.go rename to test/e2e/utils/utils.go diff --git a/test/unit_utils.go b/test/unit_utils.go new file mode 100644 index 0000000..683931c --- /dev/null +++ b/test/unit_utils.go @@ -0,0 +1,74 @@ +package test + +import ( + "fmt" + "strings" + + accessv1 "github.com/delta10/access-operator/api/v1" + operatorcontroller "github.com/delta10/access-operator/internal/controller" + "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" +) + +func NewFakeClientWithScheme(objs ...client.Object) (client.Client, *runtime.Scheme) { + testScheme := runtime.NewScheme() + gomega.Expect(accessv1.AddToScheme(testScheme)).To(gomega.Succeed()) + gomega.Expect(corev1.AddToScheme(testScheme)).To(gomega.Succeed()) + gomega.Expect(appsv1.AddToScheme(testScheme)).To(gomega.Succeed()) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithStatusSubresource(&accessv1.PostgresAccess{}, &accessv1.RabbitMQAccess{}, &accessv1.RedisAccess{}). + WithObjects(objs...). + Build() + + return fakeClient, testScheme +} + +func ReceiveEvents(events <-chan string, count int) string { + received := make([]string, 0, count) + for range count { + var event string + gomega.Eventually(events).Should(gomega.Receive(&event)) + received = append(received, event) + } + + return strings.Join(received, " ") +} + +func NewControllerSettingsConfigMap(namespace string, settings accessv1.ControllerSettings) *corev1.ConfigMap { + rawConfig, err := yaml.Marshal(settings) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: operatorcontroller.ControllerSettingsConfigMapName, + Namespace: namespace, + }, + Data: map[string]string{ + operatorcontroller.ControllerSettingsConfigMapKey: string(rawConfig), + }, + } +} + +func NewControllerSettingsConfigMapWithRawData(namespace, rawData string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: operatorcontroller.ControllerSettingsConfigMapName, + Namespace: namespace, + }, + Data: map[string]string{ + operatorcontroller.ControllerSettingsConfigMapKey: strings.TrimSpace(rawData), + }, + } +} + +func DescribeControllerSettingsConfigMap(namespace string) string { + return fmt.Sprintf("%s/%s", namespace, operatorcontroller.ControllerSettingsConfigMapName) +}