From f1e6b628dc4e3167e15a2c30730663a7f15edbe2 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 30 Apr 2026 10:36:51 +0200 Subject: [PATCH 01/13] refactor(secrets): introduce Backend interface + factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a backend-agnostic Backend interface (Exists, Get, Create, Update, Delete, Locator, SyncTags) and a NewBackend factory that picks an implementation based on the Database CR spec. Existing AWSSecretsManagerClient gains adapter methods so it satisfies Backend without renaming any of its current public methods — call sites that need AWS-specific behaviour (cross-region migration, source-side GetSecretString) continue to work unchanged. No behaviour change. Tests still pass. This is the first of two refactor commits towards supporting additional secret backends (Kubernetes Secret, Infisical Cloud). --- internal/secrets/aws_backend_adapter.go | 83 +++++++++++++++++++++++++ internal/secrets/backend.go | 53 ++++++++++++++++ internal/secrets/factory.go | 41 ++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 internal/secrets/aws_backend_adapter.go create mode 100644 internal/secrets/backend.go create mode 100644 internal/secrets/factory.go diff --git a/internal/secrets/aws_backend_adapter.go b/internal/secrets/aws_backend_adapter.go new file mode 100644 index 0000000..dbfc12e --- /dev/null +++ b/internal/secrets/aws_backend_adapter.go @@ -0,0 +1,83 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import ( + "context" + "fmt" +) + +// This file adapts the existing AWSSecretsManagerClient to the generic +// Backend interface. Existing methods (SecretExists, GetSecretARN, +// CreateSecretWithTemplate, …) are kept for use sites that still need +// AWS-specific behaviour (e.g. cross-region migration); the methods +// below provide a backend-agnostic surface for the controller to use +// going forward. + +// Compile-time check that AWSSecretsManagerClient satisfies Backend. +var _ Backend = (*AWSSecretsManagerClient)(nil) + +// Exists implements Backend. +func (c *AWSSecretsManagerClient) Exists(ctx context.Context, name string) (bool, error) { + return c.SecretExists(ctx, name) +} + +// Get implements Backend. +func (c *AWSSecretsManagerClient) Get(ctx context.Context, name string) (*DatabaseSecret, error) { + return c.GetSecret(ctx, name) +} + +// Create implements Backend. +func (c *AWSSecretsManagerClient) Create(ctx context.Context, name, description string, secret *DatabaseSecret, tags map[string]string, template string) (string, string, error) { + return c.CreateSecretWithTemplate(ctx, name, description, secret, tags, template) +} + +// Update implements Backend. +func (c *AWSSecretsManagerClient) Update(ctx context.Context, name string, secret *DatabaseSecret, template string) (string, error) { + return c.UpdateSecretWithTemplate(ctx, name, secret, template) +} + +// Delete implements Backend. +func (c *AWSSecretsManagerClient) Delete(ctx context.Context, name string, forceDelete bool) error { + return c.DeleteSecret(ctx, name, forceDelete) +} + +// Locator implements Backend. +func (c *AWSSecretsManagerClient) Locator(ctx context.Context, name string) (string, error) { + return c.GetSecretARN(ctx, name) +} + +// SyncTags implements Backend by computing the diff between the secret's +// current tags and the desired set, then issuing untag + tag calls. +func (c *AWSSecretsManagerClient) SyncTags(ctx context.Context, name string, desired map[string]string) error { + existing, err := c.GetSecretTags(ctx, name) + if err != nil { + return fmt.Errorf("failed to get existing secret tags: %w", err) + } + + var toRemove []string + for k := range existing { + if _, keep := desired[k]; !keep { + toRemove = append(toRemove, k) + } + } + + if len(toRemove) > 0 { + if err := c.UntagSecret(ctx, name, toRemove); err != nil { + return fmt.Errorf("failed to remove secret tags: %w", err) + } + } + + if len(desired) > 0 { + if err := c.TagSecret(ctx, name, desired); err != nil { + return fmt.Errorf("failed to apply secret tags: %w", err) + } + } + + return nil +} diff --git a/internal/secrets/backend.go b/internal/secrets/backend.go new file mode 100644 index 0000000..efb7a5a --- /dev/null +++ b/internal/secrets/backend.go @@ -0,0 +1,53 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import "context" + +// Backend is the interface for storing/retrieving database credentials +// generated by the operator. Each implementation persists a DatabaseSecret +// to its underlying store (AWS Secrets Manager, a Kubernetes Secret, +// Infisical, …) under a backend-specific name and returns a Locator string +// (ARN, namespace/name, Infisical path, …) used for status reporting and +// downstream consumers. +// +// Implementations must be safe for concurrent use by multiple goroutines. +type Backend interface { + // Exists reports whether a secret with the given name is currently stored. + Exists(ctx context.Context, name string) (bool, error) + + // Get reads back a previously stored DatabaseSecret. + Get(ctx context.Context, name string) (*DatabaseSecret, error) + + // Create stores a new secret. Returns the backend-specific locator and a + // version identifier. If the underlying backend has a "scheduled for + // deletion" or similar soft-deleted state, implementations should restore + // and update rather than fail. The optional template controls the JSON + // shape; pass an empty string for the default. + Create(ctx context.Context, name, description string, secret *DatabaseSecret, tags map[string]string, template string) (locator string, version string, err error) + + // Update overwrites an existing secret. Returns the new version. + // Returns *SecretNotFoundError if no secret with the name exists. + // Returns *SecretMarkedForDeletionError if the backend has soft-deleted + // the secret and it must be restored before update. + Update(ctx context.Context, name string, secret *DatabaseSecret, template string) (version string, err error) + + // Delete removes the secret. forceDelete bypasses any recovery window + // the backend supports; backends without such a concept treat both + // values the same. Deleting a non-existent secret is not an error. + Delete(ctx context.Context, name string, forceDelete bool) error + + // Locator returns the backend-specific identifier for an existing + // secret (e.g. an AWS ARN, "namespace/name" for a Kubernetes Secret, + // or the full Infisical path). + Locator(ctx context.Context, name string) (string, error) + + // SyncTags applies the given tag set to the secret, removing any tags + // not present in desired. Backends without tag support no-op. + SyncTags(ctx context.Context, name string, desired map[string]string) error +} diff --git a/internal/secrets/factory.go b/internal/secrets/factory.go new file mode 100644 index 0000000..ec8164d --- /dev/null +++ b/internal/secrets/factory.go @@ -0,0 +1,41 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import ( + "context" + "errors" + + databasev1alpha1 "opzkit/database-user-operator/api/v1alpha1" +) + +// ErrNoBackendConfigured is returned when a Database resource doesn't +// specify any of the supported destination backends. +var ErrNoBackendConfigured = errors.New("no destination backend configured: set spec.awsSecretsManager (later: spec.kubernetesSecret, spec.infisical)") + +// NewBackend selects and constructs a Backend implementation based on +// which spec field is populated on the Database CR. Region resolution and +// other backend-specific defaulting happens in the implementation +// constructors. +func NewBackend(ctx context.Context, db *databasev1alpha1.Database) (Backend, error) { + switch { + case db.Spec.AWSSecretsManager != nil: + region := db.Spec.AWSSecretsManager.Region + if err := ValidateRegion(region); err != nil { + return nil, err + } + return NewAWSSecretsManagerClient(ctx, region) + + // Future: + // case db.Spec.KubernetesSecret != nil: return NewKubernetesBackend(...) + // case db.Spec.Infisical != nil: return NewInfisicalBackend(...) + + default: + return nil, ErrNoBackendConfigured + } +} From 8cee3f8ffa57e01f37db6e440039a9cf243d9cf1 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 30 Apr 2026 10:47:25 +0200 Subject: [PATCH 02/13] refactor(secrets): replace SecretARN+SecretRegion with SecretLocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two AWS-specific Database status fields are folded into a single generic SecretLocator string. For the AWS backend the locator is the ARN, which already carries the region; the cross-region migration logic now derives the previously-used region from the stored ARN via secrets.AWSRegionFromARN. Other backends (Kubernetes Secret, Infisical — added in subsequent phases) will populate SecretLocator with their own identifiers. The "Region" printcolumn becomes "Locator" with priority=1 (hidden by default in `kubectl get`, visible with -o wide). Behaviour preserved: - Cross-region migration still detects, fetches password from old region, creates in new region, deletes old after success. - Tag-sync, idempotent reconciliation, restore-from-deletion unchanged. This is the second commit of the Phase 1 refactor (Backend interface extraction). Phase 2 adds the Kubernetes Secret backend; Phase 3 adds Infisical Cloud. --- api/v1alpha1/database_types.go | 12 ++-- .../crds/database.opzkit.io_databases.yaml | 17 ++--- internal/controller/database_controller.go | 67 ++++++++++--------- internal/secrets/aws_backend_adapter.go | 15 +++++ 4 files changed, 65 insertions(+), 46 deletions(-) diff --git a/api/v1alpha1/database_types.go b/api/v1alpha1/database_types.go index 9f05fab..9265e3c 100644 --- a/api/v1alpha1/database_types.go +++ b/api/v1alpha1/database_types.go @@ -154,8 +154,11 @@ type DatabaseStatus struct { // SecretCreated indicates whether the secret has been created SecretCreated bool `json:"secretCreated,omitempty"` - // SecretARN is the ARN of the created AWS Secrets Manager secret (if applicable) - SecretARN string `json:"secretARN,omitempty"` + // SecretLocator is the backend-specific identifier for the stored + // credential secret. For AWS Secrets Manager, this is the ARN + // (which carries the region). For a Kubernetes Secret it is + // "namespace/name". For Infisical it is the full project/env/path. + SecretLocator string `json:"secretLocator,omitempty"` // SecretVersion is the version ID of the secret SecretVersion string `json:"secretVersion,omitempty"` @@ -169,9 +172,6 @@ type DatabaseStatus struct { // ActualSecretName is the actual secret name that was created ActualSecretName string `json:"actualSecretName,omitempty"` - // SecretRegion is the AWS region where the secret is stored - SecretRegion string `json:"secretRegion,omitempty"` - // ConnectionInfo provides non-sensitive connection information ConnectionInfo ConnectionInfo `json:"connectionInfo,omitempty"` } @@ -201,7 +201,7 @@ type ConnectionInfo struct { // +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.databaseName` // +kubebuilder:printcolumn:name="Username",type=string,JSONPath=`.status.actualUsername` // +kubebuilder:printcolumn:name="SecretName",type=string,JSONPath=`.status.actualSecretName` -// +kubebuilder:printcolumn:name="Region",type=string,JSONPath=`.status.secretRegion` +// +kubebuilder:printcolumn:name="Locator",type=string,priority=1,JSONPath=`.status.secretLocator` // +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` diff --git a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml index ac1aadd..f921d30 100644 --- a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml +++ b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml @@ -29,8 +29,9 @@ spec: - jsonPath: .status.actualSecretName name: SecretName type: string - - jsonPath: .status.secretRegion - name: Region + - jsonPath: .status.secretLocator + name: Locator + priority: 1 type: string - jsonPath: .status.phase name: Phase @@ -360,10 +361,6 @@ spec: Phase represents the current phase of the Database Possible values: Pending, Creating, Ready, Failed, Deleting type: string - secretARN: - description: SecretARN is the ARN of the created AWS Secrets Manager - secret (if applicable) - type: string secretCreated: description: SecretCreated indicates whether the secret has been created type: boolean @@ -371,8 +368,12 @@ spec: description: SecretFormatVersion tracks the secret structure version (v1=old format, v2=new format with DB_HOST, etc.) type: string - secretRegion: - description: SecretRegion is the AWS region where the secret is stored + secretLocator: + description: |- + SecretLocator is the backend-specific identifier for the stored + credential secret. For AWS Secrets Manager, this is the ARN + (which carries the region). For a Kubernetes Secret it is + "namespace/name". For Infisical it is the full project/env/path. type: string secretVersion: description: SecretVersion is the version ID of the secret diff --git a/internal/controller/database_controller.go b/internal/controller/database_controller.go index 7fcc9ba..4918aa4 100644 --- a/internal/controller/database_controller.go +++ b/internal/controller/database_controller.go @@ -160,7 +160,7 @@ func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c "database", db.Spec.DatabaseName, "username", db.Status.ActualUsername, "secretName", db.Status.ActualSecretName, - "secretARN", db.Status.SecretARN, + "secretLocator", db.Status.SecretLocator, "requeueAfter", requeueAfterSuccess) return ctrl.Result{RequeueAfter: requeueAfterSuccess}, nil } @@ -362,19 +362,21 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database } else if (dbExists || userExists) && !secretExists { // Database and/or user exist but secret is missing - // Check if this is a region change scenario - regionChanged := db.Status.SecretRegion != "" && db.Status.SecretRegion != region + // Check if this is a region change scenario (AWS-specific: + // derive the previously-used region from the stored locator). + oldRegion := secrets.AWSRegionFromARN(db.Status.SecretLocator) + regionChanged := oldRegion != "" && oldRegion != region if regionChanged && db.Status.ActualSecretName != "" { logger.Info("Region change detected - attempting to retrieve password from old region", - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "newRegion", region, "secretName", db.Status.ActualSecretName) // Try to get password from old region - oldRegionClient, err := secrets.NewAWSSecretsManagerClient(ctx, db.Status.SecretRegion) + oldRegionClient, err := secrets.NewAWSSecretsManagerClient(ctx, oldRegion) if err != nil { - return fmt.Errorf("failed to create AWS client for old region (%s): %w", db.Status.SecretRegion, err) + return fmt.Errorf("failed to create AWS client for old region (%s): %w", oldRegion, err) } oldSecretExists, err := oldRegionClient.SecretExists(ctx, db.Status.ActualSecretName) @@ -384,12 +386,12 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database if oldSecretExists { logger.Info("Found secret in old region, retrieving password", - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "secretName", db.Status.ActualSecretName) oldSecret, err := oldRegionClient.GetSecret(ctx, db.Status.ActualSecretName) if err != nil { - return fmt.Errorf("failed to retrieve secret from old region (%s): %w", db.Status.SecretRegion, err) + return fmt.Errorf("failed to retrieve secret from old region (%s): %w", oldRegion, err) } password = oldSecret.DBPassword @@ -398,7 +400,7 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database } logger.Info("Successfully retrieved password from old region, will create secret in new region", - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "newRegion", region) // Update status to reflect we have the resources @@ -409,7 +411,7 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database // Note: SecretCreated will remain false until we actually create it in the new region } else { return fmt.Errorf("region changed from %s to %s but secret not found in old region - cannot recover password. Please delete the Database CR and recreate it, or manually create the secret with the correct password", - db.Status.SecretRegion, region) + oldRegion, region) } } else { // Not a region change - this is an unrecoverable error @@ -546,15 +548,17 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data return fmt.Errorf("invalid AWS region: %w", err) } - // Detect region changes - regionChanged := db.Status.SecretRegion != "" && db.Status.SecretRegion != region + // Detect region changes by reading the previously-used region from + // the stored ARN locator (AWS-specific format). + oldRegion := secrets.AWSRegionFromARN(db.Status.SecretLocator) + regionChanged := oldRegion != "" && oldRegion != region if regionChanged { logger.Info("Region change detected - secret will be created in new region", "database", db.Spec.DatabaseName, "secretName", secretName, - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "newRegion", region, - "warning", fmt.Sprintf("Secret may still exist in old region (%s) and should be manually deleted if no longer needed", db.Status.SecretRegion)) + "warning", fmt.Sprintf("Secret may still exist in old region (%s) and should be manually deleted if no longer needed", oldRegion)) } if isMigration { @@ -591,26 +595,26 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data } // If region changed and secret doesn't exist in new region, check old region - if regionChanged && !exists && db.Status.SecretRegion != "" { + if regionChanged && !exists && oldRegion != "" { logger.Info("Checking for secret in old region", "secretName", secretName, - "oldRegion", db.Status.SecretRegion) + "oldRegion", oldRegion) - oldRegionClient, err := secrets.NewAWSSecretsManagerClient(ctx, db.Status.SecretRegion) + oldRegionClient, err := secrets.NewAWSSecretsManagerClient(ctx, oldRegion) if err != nil { logger.Error(err, "Failed to create client for old region", - "oldRegion", db.Status.SecretRegion) + "oldRegion", oldRegion) } else { oldExists, err := oldRegionClient.SecretExists(ctx, secretName) if err != nil { logger.Error(err, "Failed to check secret in old region", - "oldRegion", db.Status.SecretRegion) + "oldRegion", oldRegion) } else if oldExists { logger.Info("Secret exists in old region - will create in new region", "secretName", secretName, - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "newRegion", region, - "action", fmt.Sprintf("Please manually delete secret from %s if no longer needed", db.Status.SecretRegion)) + "action", fmt.Sprintf("Please manually delete secret from %s if no longer needed", oldRegion)) } } } @@ -741,43 +745,42 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data // If region changed, delete secret from old region ONLY after successful creation in new region // Only delete if we have a valid secretARN (confirming successful creation in new region) - if regionChanged && db.Status.SecretRegion != "" && secretARN != "" { + if regionChanged && oldRegion != "" && secretARN != "" { logger.Info("Deleting secret from old region after successful migration", "secretName", secretName, - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "newRegion", region, "newSecretARN", secretARN) - oldRegionClient, err := secrets.NewAWSSecretsManagerClient(ctx, db.Status.SecretRegion) + oldRegionClient, err := secrets.NewAWSSecretsManagerClient(ctx, oldRegion) if err != nil { logger.Error(err, "Failed to create AWS client for old region to delete secret", - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "warning", "Secret may still exist in old region and should be manually deleted") } else { // Use force delete to remove immediately without recovery window if err := oldRegionClient.DeleteSecret(ctx, secretName, true); err != nil { logger.Error(err, "Failed to delete secret from old region", - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "secretName", secretName, "warning", "Secret may still exist in old region and should be manually deleted") } else { logger.Info("Successfully deleted secret from old region", "secretName", secretName, - "oldRegion", db.Status.SecretRegion) + "oldRegion", oldRegion) } } - } else if regionChanged && db.Status.SecretRegion != "" && secretARN == "" { + } else if regionChanged && oldRegion != "" && secretARN == "" { logger.Error(nil, "Region changed but secret not successfully created in new region - keeping secret in old region", - "oldRegion", db.Status.SecretRegion, + "oldRegion", oldRegion, "newRegion", region, "secretName", secretName) } db.Status.SecretCreated = true - db.Status.SecretARN = secretARN + db.Status.SecretLocator = secretARN db.Status.SecretVersion = versionID db.Status.SecretFormatVersion = "v2" - db.Status.SecretRegion = region db.Status.ConnectionInfo = databasev1alpha1.ConnectionInfo{ Host: connInfo.Host, Port: port, @@ -958,7 +961,7 @@ func (r *DatabaseReconciler) reconcileDelete(ctx context.Context, db *databasev1 "database", db.Spec.DatabaseName, "username", db.Status.ActualUsername, "secretName", db.Status.ActualSecretName, - "secretARN", db.Status.SecretARN, + "secretLocator", db.Status.SecretLocator, "databaseRetained", db.Status.DatabaseCreated, "userRetained", db.Status.UserCreated, "secretRetained", db.Status.SecretCreated) diff --git a/internal/secrets/aws_backend_adapter.go b/internal/secrets/aws_backend_adapter.go index dbfc12e..1329f4e 100644 --- a/internal/secrets/aws_backend_adapter.go +++ b/internal/secrets/aws_backend_adapter.go @@ -10,8 +10,23 @@ package secrets import ( "context" "fmt" + "strings" ) +// AWSRegionFromARN extracts the region from an AWS ARN. Returns "" if +// the locator isn't a recognisable AWS ARN. Used by the controller to +// derive the previously-used region from a stored Status.SecretLocator +// without keeping a separate AWS-specific status field. +// +// AWS ARN format: arn::::: +func AWSRegionFromARN(arn string) string { + parts := strings.Split(arn, ":") + if len(parts) >= 6 && parts[0] == "arn" { + return parts[3] + } + return "" +} + // This file adapts the existing AWSSecretsManagerClient to the generic // Backend interface. Existing methods (SecretExists, GetSecretARN, // CreateSecretWithTemplate, …) are kept for use sites that still need From c1cab0d0729e7aa728f5f5d3f21b3febc81223d4 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 30 Apr 2026 10:52:21 +0200 Subject: [PATCH 03/13] chore(crds): regenerate from controller-gen v0.19.0 Output of `make manifests && make helm-crds`. No semantic change beyond the field rename done in the previous commit; this just refreshes the CRD to the current controller-gen formatting (description reflow, Condition struct doc, etc.) and populates config/crd/bases/ which was empty. --- .../crds/database.opzkit.io_databases.yaml | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml index f921d30..48f254d 100644 --- a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml +++ b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: databases.database.opzkit.io spec: group: database.opzkit.io @@ -257,16 +257,8 @@ spec: description: Conditions represent the latest available observations of the Database's state items: - description: "Condition contains details for one aspect of the current - state of this API Resource.\n---\nThis struct is intended for - direct use as an array at the field path .status.conditions. For - example,\n\n\n\ttype FooStatus struct{\n\t // Represents the - observations of a foo's current state.\n\t // Known .status.conditions.type - are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // - +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t - \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" - patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t - \ // other fields\n\t}" + description: Condition contains details for one aspect of the current + state of this API Resource. properties: lastTransitionTime: description: |- @@ -307,12 +299,7 @@ spec: - Unknown type: string type: - description: |- - type of condition in CamelCase or in foo.example.com/CamelCase. - --- - Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be - useful (see .node.status.conditions), the ability to deconflict is important. - The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + 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 From 61e7d39de84d7101d84a71197d64200f14d0d8ea Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 30 Apr 2026 11:08:01 +0200 Subject: [PATCH 04/13] refactor!(api): nest backends under spec.secretBackend / spec.connectionString MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE — drops the v0.1.x flat-field shape: spec.connectionStringSecretRef → spec.connectionString.kubernetes spec.connectionStringAWSSecretRef → spec.connectionString.aws spec.awsSecretsManager → spec.secretBackend.aws The new spec.secretBackend.{aws,kubernetes,infisical} oneOf is the seam for upcoming backends. Phase 2 fills in spec.secretBackend.kubernetes (KubernetesSecretBackend already declared). Phase 3 fills in the Infisical backend (InfisicalSecretBackend already declared with ProjectSlug/EnvironmentSlug/AuthSecretRef etc). Validation: the controller's existing oneOf checks for connectionString remain; secrets.NewBackend returns ErrMultipleBackendsConfigured / ErrNoBackendConfigured if zero or multiple secretBackend.* are set. Sample manifests, CRDs, and unit tests updated. AWSSecretsManagerConfig, SecretKeyReference, AWSSecretReference types removed. Refs Phase 2 / Phase 3 / Phase 4 release work. --- api/v1alpha1/database_types.go | 136 ++++++-- api/v1alpha1/zz_generated.deepcopy.go | 142 ++++++-- .../samples/database_v1alpha1_database.yaml | 55 +--- .../database_v1alpha1_database_aws.yaml | 42 +-- ...abase_v1alpha1_database_custom_secret.yaml | 36 +- .../crds/database.opzkit.io_databases.yaml | 309 +++++++++++------- internal/controller/database_controller.go | 63 ++-- .../database_controller_unit_test.go | 83 ++--- internal/secrets/factory.go | 45 ++- 9 files changed, 567 insertions(+), 344 deletions(-) diff --git a/api/v1alpha1/database_types.go b/api/v1alpha1/database_types.go index 9265e3c..86ba3df 100644 --- a/api/v1alpha1/database_types.go +++ b/api/v1alpha1/database_types.go @@ -36,18 +36,11 @@ type DatabaseSpec struct { // +kubebuilder:validation:Pattern=`^[a-z][a-z0-9_]*$` DatabaseName string `json:"databaseName"` - // ConnectionStringSecretRef references a Kubernetes Secret containing the admin connection string - // to the existing database instance. Must have proper permissions to create databases and users. - // Either ConnectionStringSecretRef or ConnectionStringAWSSecretRef must be specified. - // Note: Created database credentials will always be stored in AWS Secrets Manager. - // +optional - ConnectionStringSecretRef *SecretKeyReference `json:"connectionStringSecretRef,omitempty"` - - // ConnectionStringAWSSecretRef references an AWS Secrets Manager secret containing the admin connection string - // Either ConnectionStringSecretRef or ConnectionStringAWSSecretRef must be specified. - // Note: Created database credentials will always be stored in AWS Secrets Manager. - // +optional - ConnectionStringAWSSecretRef *AWSSecretReference `json:"connectionStringAWSSecretRef,omitempty"` + // ConnectionString sources the admin DSN used to create the database + // and user. Exactly one of the inner fields (aws, kubernetes, ...) + // must be set. + // +kubebuilder:validation:Required + ConnectionString ConnectionStringSource `json:"connectionString"` // Username for the database user to be created // Defaults to the DatabaseName if not specified @@ -56,8 +49,10 @@ type DatabaseSpec struct { // +kubebuilder:validation:Pattern=`^[a-z][a-z0-9_]*$` Username string `json:"username,omitempty"` - // SecretName is the name/path for storing the created credentials in AWS Secrets Manager - // Defaults to rds// + // SecretName is the name/path used by the chosen SecretBackend for the + // generated user credentials. For AWS this is the Secrets Manager + // secret name; for Kubernetes the Secret name; for Infisical the path + // inside the configured environment. Defaults to rds//. // +optional SecretName string `json:"secretName,omitempty"` @@ -72,10 +67,11 @@ type DatabaseSpec struct { // +kubebuilder:default=true RetainOnDelete *bool `json:"retainOnDelete,omitempty"` - // AWSSecretsManager contains AWS Secrets Manager specific configuration for storing created credentials - // All created credentials are stored in AWS Secrets Manager regardless of connection string source - // +optional - AWSSecretsManager *AWSSecretsManagerConfig `json:"awsSecretsManager,omitempty"` + // SecretBackend chooses where to store the generated user credentials. + // Exactly one of the inner fields (aws, kubernetes, infisical) must + // be set. + // +kubebuilder:validation:Required + SecretBackend SecretBackend `json:"secretBackend"` // SecretTemplate is a Go template for customizing the secret structure // Available variables: .DBHost, .DBPort, .DBName, .DBUsername, .DBPassword, .DatabaseURL, .Engine @@ -85,8 +81,26 @@ type DatabaseSpec struct { SecretTemplate string `json:"secretTemplate,omitempty"` } -// AWSSecretsManagerConfig contains AWS Secrets Manager specific settings -type AWSSecretsManagerConfig struct { +// SecretBackend selects where the generated user credentials are stored. +// Exactly one inner field must be set; the controller fails reconciliation +// if zero or multiple are configured. +type SecretBackend struct { + // AWS stores credentials in AWS Secrets Manager. + // +optional + AWS *AWSSecretBackend `json:"aws,omitempty"` + + // Kubernetes stores credentials as a Kubernetes Secret. + // +optional + Kubernetes *KubernetesSecretBackend `json:"kubernetes,omitempty"` + + // Infisical stores credentials in Infisical Cloud (or self-hosted) + // via Universal Auth. + // +optional + Infisical *InfisicalSecretBackend `json:"infisical,omitempty"` +} + +// AWSSecretBackend contains AWS Secrets Manager configuration. +type AWSSecretBackend struct { // Region is the AWS region for Secrets Manager // +kubebuilder:validation:Required // +kubebuilder:validation:Enum=us-east-1;us-east-2;us-west-1;us-west-2;us-gov-west-1;us-gov-east-1;af-south-1;ap-east-1;ap-south-1;ap-south-2;ap-northeast-1;ap-northeast-2;ap-northeast-3;ap-southeast-1;ap-southeast-2;ap-southeast-3;ap-southeast-4;ca-central-1;ca-west-1;eu-central-1;eu-central-2;eu-west-1;eu-west-2;eu-west-3;eu-south-1;eu-south-2;eu-north-1;me-south-1;me-central-1;sa-east-1;cn-north-1;cn-northwest-1;il-central-1 @@ -101,26 +115,67 @@ type AWSSecretsManagerConfig struct { Tags map[string]string `json:"tags,omitempty"` } -// SecretKeyReference references a key in a Kubernetes Secret -type SecretKeyReference struct { - // Name of the secret +// KubernetesSecretBackend stores generated credentials in a Kubernetes +// Secret. The Secret is created in `namespace`; the secret name comes from +// spec.secretName (default rds//). +type KubernetesSecretBackend struct { + // Namespace the Secret is created in. Defaults to the namespace of + // the Database resource. + // +optional + Namespace string `json:"namespace,omitempty"` +} + +// InfisicalSecretBackend stores generated credentials in Infisical via +// Universal Auth. The clientId/clientSecret pair is read from a +// Kubernetes Secret referenced by AuthSecretRef; the API uses +// HostAPI / ProjectSlug / EnvironmentSlug / SecretsPath to address the +// target. +type InfisicalSecretBackend struct { + // HostAPI is the Infisical API endpoint. Default: https://app.infisical.com + // +optional + // +kubebuilder:default="https://app.infisical.com" + HostAPI string `json:"hostAPI,omitempty"` + + // ProjectSlug is the Infisical project slug. // +kubebuilder:validation:Required - Name string `json:"name"` + ProjectSlug string `json:"projectSlug"` + + // EnvironmentSlug is the Infisical environment slug (e.g. dev, prod). + // +kubebuilder:validation:Required + EnvironmentSlug string `json:"environmentSlug"` - // Key within the secret - // Defaults to "connectionString" + // SecretsPath is the path inside the environment. Default: "/" // +optional - Key string `json:"key,omitempty"` + // +kubebuilder:default="/" + SecretsPath string `json:"secretsPath,omitempty"` + + // AuthSecretRef references a Kubernetes Secret in the same namespace + // as the Database holding clientId/clientSecret keys for Infisical + // Universal Auth. + // +kubebuilder:validation:Required + AuthSecretRef KubernetesSecretRef `json:"authSecretRef"` +} + +// ConnectionStringSource selects where the admin DSN is read from. +// Exactly one inner field must be set. +type ConnectionStringSource struct { + // AWS reads the connection string from an AWS Secrets Manager secret. + // +optional + AWS *AWSConnectionStringRef `json:"aws,omitempty"` + + // Kubernetes reads the connection string from a Kubernetes Secret in + // the same namespace as the Database. + // +optional + Kubernetes *KubernetesConnectionStringRef `json:"kubernetes,omitempty"` } -// AWSSecretReference references an AWS Secrets Manager secret -type AWSSecretReference struct { +// AWSConnectionStringRef points at a key in an AWS Secrets Manager secret. +type AWSConnectionStringRef struct { // SecretName is the name or ARN of the AWS Secrets Manager secret // +kubebuilder:validation:Required SecretName string `json:"secretName"` - // Key within the secret JSON - // Defaults to "connectionString" + // Key within the secret JSON. Defaults to "connectionString". // +optional Key string `json:"key,omitempty"` @@ -130,6 +185,25 @@ type AWSSecretReference struct { Region string `json:"region"` } +// KubernetesConnectionStringRef points at a key in a Kubernetes Secret. +type KubernetesConnectionStringRef struct { + // Name of the secret in the same namespace as the Database. + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Key within the secret. Defaults to "connectionString". + // +optional + Key string `json:"key,omitempty"` +} + +// KubernetesSecretRef is a generic reference to a Kubernetes Secret in +// the same namespace as the Database resource. +type KubernetesSecretRef struct { + // Name of the Secret. + // +kubebuilder:validation:Required + Name string `json:"name"` +} + // DatabaseStatus defines the observed state of Database type DatabaseStatus struct { // Conditions represent the latest available observations of the Database's state diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 83970e9..4093faf 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -17,22 +17,22 @@ import ( ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AWSSecretReference) DeepCopyInto(out *AWSSecretReference) { +func (in *AWSConnectionStringRef) DeepCopyInto(out *AWSConnectionStringRef) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSSecretReference. -func (in *AWSSecretReference) DeepCopy() *AWSSecretReference { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSConnectionStringRef. +func (in *AWSConnectionStringRef) DeepCopy() *AWSConnectionStringRef { if in == nil { return nil } - out := new(AWSSecretReference) + out := new(AWSConnectionStringRef) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AWSSecretsManagerConfig) DeepCopyInto(out *AWSSecretsManagerConfig) { +func (in *AWSSecretBackend) DeepCopyInto(out *AWSSecretBackend) { *out = *in if in.Tags != nil { in, out := &in.Tags, &out.Tags @@ -43,12 +43,12 @@ func (in *AWSSecretsManagerConfig) DeepCopyInto(out *AWSSecretsManagerConfig) { } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSSecretsManagerConfig. -func (in *AWSSecretsManagerConfig) DeepCopy() *AWSSecretsManagerConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSSecretBackend. +func (in *AWSSecretBackend) DeepCopy() *AWSSecretBackend { if in == nil { return nil } - out := new(AWSSecretsManagerConfig) + out := new(AWSSecretBackend) in.DeepCopyInto(out) return out } @@ -68,6 +68,31 @@ func (in *ConnectionInfo) DeepCopy() *ConnectionInfo { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionStringSource) DeepCopyInto(out *ConnectionStringSource) { + *out = *in + if in.AWS != nil { + in, out := &in.AWS, &out.AWS + *out = new(AWSConnectionStringRef) + **out = **in + } + if in.Kubernetes != nil { + in, out := &in.Kubernetes, &out.Kubernetes + *out = new(KubernetesConnectionStringRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionStringSource. +func (in *ConnectionStringSource) DeepCopy() *ConnectionStringSource { + if in == nil { + return nil + } + out := new(ConnectionStringSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Database) DeepCopyInto(out *Database) { *out = *in @@ -130,16 +155,7 @@ func (in *DatabaseList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DatabaseSpec) DeepCopyInto(out *DatabaseSpec) { *out = *in - if in.ConnectionStringSecretRef != nil { - in, out := &in.ConnectionStringSecretRef, &out.ConnectionStringSecretRef - *out = new(SecretKeyReference) - **out = **in - } - if in.ConnectionStringAWSSecretRef != nil { - in, out := &in.ConnectionStringAWSSecretRef, &out.ConnectionStringAWSSecretRef - *out = new(AWSSecretReference) - **out = **in - } + in.ConnectionString.DeepCopyInto(&out.ConnectionString) if in.Privileges != nil { in, out := &in.Privileges, &out.Privileges *out = make([]string, len(*in)) @@ -150,11 +166,7 @@ func (in *DatabaseSpec) DeepCopyInto(out *DatabaseSpec) { *out = new(bool) **out = **in } - if in.AWSSecretsManager != nil { - in, out := &in.AWSSecretsManager, &out.AWSSecretsManager - *out = new(AWSSecretsManagerConfig) - (*in).DeepCopyInto(*out) - } + in.SecretBackend.DeepCopyInto(&out.SecretBackend) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseSpec. @@ -191,16 +203,92 @@ func (in *DatabaseStatus) DeepCopy() *DatabaseStatus { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SecretKeyReference) DeepCopyInto(out *SecretKeyReference) { +func (in *InfisicalSecretBackend) DeepCopyInto(out *InfisicalSecretBackend) { + *out = *in + out.AuthSecretRef = in.AuthSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalSecretBackend. +func (in *InfisicalSecretBackend) DeepCopy() *InfisicalSecretBackend { + if in == nil { + return nil + } + out := new(InfisicalSecretBackend) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesConnectionStringRef) DeepCopyInto(out *KubernetesConnectionStringRef) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyReference. -func (in *SecretKeyReference) DeepCopy() *SecretKeyReference { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesConnectionStringRef. +func (in *KubernetesConnectionStringRef) DeepCopy() *KubernetesConnectionStringRef { + if in == nil { + return nil + } + out := new(KubernetesConnectionStringRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesSecretBackend) DeepCopyInto(out *KubernetesSecretBackend) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesSecretBackend. +func (in *KubernetesSecretBackend) DeepCopy() *KubernetesSecretBackend { + if in == nil { + return nil + } + out := new(KubernetesSecretBackend) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesSecretRef) DeepCopyInto(out *KubernetesSecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesSecretRef. +func (in *KubernetesSecretRef) DeepCopy() *KubernetesSecretRef { + if in == nil { + return nil + } + out := new(KubernetesSecretRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretBackend) DeepCopyInto(out *SecretBackend) { + *out = *in + if in.AWS != nil { + in, out := &in.AWS, &out.AWS + *out = new(AWSSecretBackend) + (*in).DeepCopyInto(*out) + } + if in.Kubernetes != nil { + in, out := &in.Kubernetes, &out.Kubernetes + *out = new(KubernetesSecretBackend) + **out = **in + } + if in.Infisical != nil { + in, out := &in.Infisical, &out.Infisical + *out = new(InfisicalSecretBackend) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretBackend. +func (in *SecretBackend) DeepCopy() *SecretBackend { if in == nil { return nil } - out := new(SecretKeyReference) + out := new(SecretBackend) in.DeepCopyInto(out) return out } diff --git a/config/samples/database_v1alpha1_database.yaml b/config/samples/database_v1alpha1_database.yaml index cf7e6a2..f3e0773 100644 --- a/config/samples/database_v1alpha1_database.yaml +++ b/config/samples/database_v1alpha1_database.yaml @@ -3,46 +3,27 @@ kind: Database metadata: name: myapp-database spec: - # Database engine type engine: postgres - - # Name of the database to create databaseName: myapp_db - # Option 1: Reference to Kubernetes Secret containing the admin connection string - # Use either connectionStringSecretRef OR connectionStringAWSSecretRef, not both - # Note: Created database credentials will ALWAYS be stored in AWS Secrets Manager - connectionStringSecretRef: - name: postgres-admin-connection - key: connectionString # optional, defaults to "connectionString" - - # Option 2: Reference to AWS Secrets Manager secret containing the admin connection string - # Uncomment to use AWS Secrets Manager for the admin connection string - # Note: Created database credentials will ALWAYS be stored in AWS Secrets Manager - # connectionStringAWSSecretRef: - # secretName: rds/admin/postgres-connection - # key: connectionString # optional, defaults to "connectionString" - # region: us-east-1 # optional, uses default AWS region if not specified - - # Username for the database user (optional, defaults to databaseName) - # username: myapp_user - - # Secret name for storing the created credentials in AWS Secrets Manager (optional) - # Defaults to rds// - # secretName: rds/postgres/myapp_db - - # Privileges to grant (optional, defaults to ["ALL"]) - #privileges: - # - ALL + # Source of the admin DSN. Pick one of: + # connectionString.kubernetes — admin DSN in a Secret in the same namespace + # connectionString.aws — admin DSN in AWS Secrets Manager + connectionString: + kubernetes: + name: postgres-admin-connection + key: connectionString # optional, defaults to "connectionString" - # Retain database/user on CR deletion (optional, defaults to true) retainOnDelete: false - # AWS Secrets Manager configuration for storing created credentials (optional) - # Created credentials are ALWAYS stored in AWS Secrets Manager - awsSecretsManager: - region: us-east-1 # required - AWS region for Secrets Manager - description: "Database credentials for myapp" - tags: - Environment: production - Application: myapp + # Destination for the generated user credentials. Pick one of: + # secretBackend.aws — AWS Secrets Manager + # secretBackend.kubernetes — Kubernetes Secret in the chosen namespace + # secretBackend.infisical — Infisical (Cloud or self-hosted) via Universal Auth + secretBackend: + aws: + region: us-east-1 + description: "Database credentials for myapp" + tags: + Environment: production + Application: myapp diff --git a/config/samples/database_v1alpha1_database_aws.yaml b/config/samples/database_v1alpha1_database_aws.yaml index ffd8b42..6859eab 100644 --- a/config/samples/database_v1alpha1_database_aws.yaml +++ b/config/samples/database_v1alpha1_database_aws.yaml @@ -3,38 +3,30 @@ kind: Database metadata: name: myapp-database-aws spec: - # Database engine type engine: postgres - - # Name of the database to create databaseName: myapp_db - # Use AWS Secrets Manager for the admin connection string - # Note: Created database credentials will also be stored in AWS Secrets Manager - connectionStringAWSSecretRef: - secretName: rds/admin/postgres-connection - key: connectionString # optional, defaults to "connectionString" - region: us-east-1 # optional, uses default AWS region if not specified + # Source of the admin DSN: read from AWS Secrets Manager. + connectionString: + aws: + secretName: rds/admin/postgres-connection + key: connectionString # optional, defaults to "connectionString" + region: us-east-1 - # Username for the database user (optional, defaults to databaseName) + # Optional — defaults to databaseName username: myapp_user - # Secret name for storing the created credentials in AWS Secrets Manager (optional) - # Defaults to rds// + # Optional — defaults to rds// secretName: rds/postgres/myapp_db - # Privileges to grant (optional, defaults to ["ALL"]) - privileges: - - ALL - - # Retain database/user on CR deletion (optional, defaults to true) + privileges: [ALL] retainOnDelete: true - # AWS Secrets Manager configuration for storing created credentials (optional) - # Created credentials are ALWAYS stored in AWS Secrets Manager - awsSecretsManager: - region: us-east-1 # required - AWS region for Secrets Manager - description: "Database credentials for myapp" - tags: - Environment: production - Application: myapp + # Destination for the generated user credentials. + secretBackend: + aws: + region: us-east-1 + description: "Database credentials for myapp" + tags: + Environment: production + Application: myapp diff --git a/config/samples/database_v1alpha1_database_custom_secret.yaml b/config/samples/database_v1alpha1_database_custom_secret.yaml index f721cd3..f29bcbc 100644 --- a/config/samples/database_v1alpha1_database_custom_secret.yaml +++ b/config/samples/database_v1alpha1_database_custom_secret.yaml @@ -5,13 +5,15 @@ metadata: spec: engine: postgres databaseName: myapp_db - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 - tags: - Environment: production - Application: myapp + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 + tags: + Environment: production + Application: myapp # Custom secret template for Spring Boot applications # The template uses Go template syntax and must produce valid JSON # Available variables: .DBHost, .DBPort, .DBName, .DBUsername, .DBPassword, .DatabaseURL, .Engine @@ -30,10 +32,12 @@ metadata: spec: engine: mysql databaseName: myapp_mysql - connectionStringSecretRef: - name: mysql-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: mysql-admin + secretBackend: + aws: + region: us-east-1 # Simple connection string only template secretTemplate: | { @@ -47,10 +51,12 @@ metadata: spec: engine: postgres databaseName: myapp_env - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 # Environment variable style naming secretTemplate: | { diff --git a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml index 48f254d..3ab66d2 100644 --- a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml +++ b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml @@ -64,134 +64,79 @@ spec: spec: description: DatabaseSpec defines the desired state of Database properties: - awsSecretsManager: + connectionString: description: |- - AWSSecretsManager contains AWS Secrets Manager specific configuration for storing created credentials - All created credentials are stored in AWS Secrets Manager regardless of connection string source + ConnectionString sources the admin DSN used to create the database + and user. Exactly one of the inner fields (aws, kubernetes, ...) + must be set. properties: - description: - description: Description is the description for the AWS Secrets - Manager secret - type: string - region: - description: Region is the AWS region for Secrets Manager - enum: - - us-east-1 - - us-east-2 - - us-west-1 - - us-west-2 - - us-gov-west-1 - - us-gov-east-1 - - af-south-1 - - ap-east-1 - - ap-south-1 - - ap-south-2 - - ap-northeast-1 - - ap-northeast-2 - - ap-northeast-3 - - ap-southeast-1 - - ap-southeast-2 - - ap-southeast-3 - - ap-southeast-4 - - ca-central-1 - - ca-west-1 - - eu-central-1 - - eu-central-2 - - eu-west-1 - - eu-west-2 - - eu-west-3 - - eu-south-1 - - eu-south-2 - - eu-north-1 - - me-south-1 - - me-central-1 - - sa-east-1 - - cn-north-1 - - cn-northwest-1 - - il-central-1 - type: string - tags: - additionalProperties: - type: string - description: Tags are tags to apply to the AWS Secrets Manager - secret + aws: + description: AWS reads the connection string from an AWS Secrets + Manager secret. + properties: + key: + description: Key within the secret JSON. Defaults to "connectionString". + type: string + region: + description: Region is the AWS region for Secrets Manager + enum: + - us-east-1 + - us-east-2 + - us-west-1 + - us-west-2 + - us-gov-west-1 + - us-gov-east-1 + - af-south-1 + - ap-east-1 + - ap-south-1 + - ap-south-2 + - ap-northeast-1 + - ap-northeast-2 + - ap-northeast-3 + - ap-southeast-1 + - ap-southeast-2 + - ap-southeast-3 + - ap-southeast-4 + - ca-central-1 + - ca-west-1 + - eu-central-1 + - eu-central-2 + - eu-west-1 + - eu-west-2 + - eu-west-3 + - eu-south-1 + - eu-south-2 + - eu-north-1 + - me-south-1 + - me-central-1 + - sa-east-1 + - cn-north-1 + - cn-northwest-1 + - il-central-1 + type: string + secretName: + description: SecretName is the name or ARN of the AWS Secrets + Manager secret + type: string + required: + - region + - secretName type: object - required: - - region - type: object - connectionStringAWSSecretRef: - description: |- - ConnectionStringAWSSecretRef references an AWS Secrets Manager secret containing the admin connection string - Either ConnectionStringSecretRef or ConnectionStringAWSSecretRef must be specified. - Note: Created database credentials will always be stored in AWS Secrets Manager. - properties: - key: + kubernetes: description: |- - Key within the secret JSON - Defaults to "connectionString" - type: string - region: - description: Region is the AWS region for Secrets Manager - enum: - - us-east-1 - - us-east-2 - - us-west-1 - - us-west-2 - - us-gov-west-1 - - us-gov-east-1 - - af-south-1 - - ap-east-1 - - ap-south-1 - - ap-south-2 - - ap-northeast-1 - - ap-northeast-2 - - ap-northeast-3 - - ap-southeast-1 - - ap-southeast-2 - - ap-southeast-3 - - ap-southeast-4 - - ca-central-1 - - ca-west-1 - - eu-central-1 - - eu-central-2 - - eu-west-1 - - eu-west-2 - - eu-west-3 - - eu-south-1 - - eu-south-2 - - eu-north-1 - - me-south-1 - - me-central-1 - - sa-east-1 - - cn-north-1 - - cn-northwest-1 - - il-central-1 - type: string - secretName: - description: SecretName is the name or ARN of the AWS Secrets - Manager secret - type: string - required: - - region - - secretName - type: object - connectionStringSecretRef: - description: |- - ConnectionStringSecretRef references a Kubernetes Secret containing the admin connection string - to the existing database instance. Must have proper permissions to create databases and users. - Either ConnectionStringSecretRef or ConnectionStringAWSSecretRef must be specified. - Note: Created database credentials will always be stored in AWS Secrets Manager. - properties: - key: - description: |- - Key within the secret - Defaults to "connectionString" - type: string - name: - description: Name of the secret - type: string - required: - - name + Kubernetes reads the connection string from a Kubernetes Secret in + the same namespace as the Database. + properties: + key: + description: Key within the secret. Defaults to "connectionString". + type: string + name: + description: Name of the secret in the same namespace as the + Database. + type: string + required: + - name + type: object type: object databaseName: description: DatabaseName is the name of the database to create @@ -221,10 +166,120 @@ spec: RetainOnDelete determines whether to retain the database and user when the CR is deleted Defaults to true (retains resources on deletion) type: boolean + secretBackend: + description: |- + SecretBackend chooses where to store the generated user credentials. + Exactly one of the inner fields (aws, kubernetes, infisical) must + be set. + properties: + aws: + description: AWS stores credentials in AWS Secrets Manager. + properties: + description: + description: Description is the description for the AWS Secrets + Manager secret + type: string + region: + description: Region is the AWS region for Secrets Manager + enum: + - us-east-1 + - us-east-2 + - us-west-1 + - us-west-2 + - us-gov-west-1 + - us-gov-east-1 + - af-south-1 + - ap-east-1 + - ap-south-1 + - ap-south-2 + - ap-northeast-1 + - ap-northeast-2 + - ap-northeast-3 + - ap-southeast-1 + - ap-southeast-2 + - ap-southeast-3 + - ap-southeast-4 + - ca-central-1 + - ca-west-1 + - eu-central-1 + - eu-central-2 + - eu-west-1 + - eu-west-2 + - eu-west-3 + - eu-south-1 + - eu-south-2 + - eu-north-1 + - me-south-1 + - me-central-1 + - sa-east-1 + - cn-north-1 + - cn-northwest-1 + - il-central-1 + type: string + tags: + additionalProperties: + type: string + description: Tags are tags to apply to the AWS Secrets Manager + secret + type: object + required: + - region + type: object + infisical: + description: |- + Infisical stores credentials in Infisical Cloud (or self-hosted) + via Universal Auth. + properties: + authSecretRef: + description: |- + AuthSecretRef references a Kubernetes Secret in the same namespace + as the Database holding clientId/clientSecret keys for Infisical + Universal Auth. + properties: + name: + description: Name of the Secret. + type: string + required: + - name + type: object + environmentSlug: + description: EnvironmentSlug is the Infisical environment + slug (e.g. dev, prod). + type: string + hostAPI: + default: https://app.infisical.com + description: 'HostAPI is the Infisical API endpoint. Default: + https://app.infisical.com' + type: string + projectSlug: + description: ProjectSlug is the Infisical project slug. + type: string + secretsPath: + default: / + description: 'SecretsPath is the path inside the environment. + Default: "/"' + type: string + required: + - authSecretRef + - environmentSlug + - projectSlug + type: object + kubernetes: + description: Kubernetes stores credentials as a Kubernetes Secret. + properties: + namespace: + description: |- + Namespace the Secret is created in. Defaults to the namespace of + the Database resource. + type: string + type: object + type: object secretName: description: |- - SecretName is the name/path for storing the created credentials in AWS Secrets Manager - Defaults to rds// + SecretName is the name/path used by the chosen SecretBackend for the + generated user credentials. For AWS this is the Secrets Manager + secret name; for Kubernetes the Secret name; for Infisical the path + inside the configured environment. Defaults to rds//. type: string secretTemplate: description: |- @@ -241,8 +296,10 @@ spec: pattern: ^[a-z][a-z0-9_]*$ type: string required: + - connectionString - databaseName - engine + - secretBackend type: object status: description: DatabaseStatus defines the observed state of Database diff --git a/internal/controller/database_controller.go b/internal/controller/database_controller.go index 4918aa4..13fae47 100644 --- a/internal/controller/database_controller.go +++ b/internal/controller/database_controller.go @@ -313,8 +313,8 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database // Always update tags to ensure they're in sync with spec desiredTags := map[string]string{"ManagedBy": "database-user-operator"} - if db.Spec.AWSSecretsManager != nil { - for k, v := range db.Spec.AWSSecretsManager.Tags { + if db.Spec.SecretBackend.AWS != nil { + for k, v := range db.Spec.SecretBackend.AWS.Tags { desiredTags[k] = v } } @@ -535,9 +535,9 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data // Determine region: use awsSecretsManager.region if set, otherwise use connectionStringAWSSecretRef.region region := r.getRegion(db) var regionSource string - if db.Spec.AWSSecretsManager != nil && db.Spec.AWSSecretsManager.Region != "" { + if db.Spec.SecretBackend.AWS != nil && db.Spec.SecretBackend.AWS.Region != "" { regionSource = "spec.awsSecretsManager.region" - } else if db.Spec.ConnectionStringAWSSecretRef != nil && db.Spec.ConnectionStringAWSSecretRef.Region != "" { + } else if db.Spec.ConnectionString.AWS != nil && db.Spec.ConnectionString.AWS.Region != "" { regionSource = "spec.connectionStringAWSSecretRef.region" } else { regionSource = "AWS SDK default (environment/instance metadata)" @@ -677,11 +677,11 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data if createSecret { description := "Database credentials for " + db.Spec.DatabaseName tags := map[string]string{"ManagedBy": "database-user-operator"} - if db.Spec.AWSSecretsManager != nil { - if db.Spec.AWSSecretsManager.Description != "" { - description = db.Spec.AWSSecretsManager.Description + if db.Spec.SecretBackend.AWS != nil { + if db.Spec.SecretBackend.AWS.Description != "" { + description = db.Spec.SecretBackend.AWS.Description } - for k, v := range db.Spec.AWSSecretsManager.Tags { + for k, v := range db.Spec.SecretBackend.AWS.Tags { tags[k] = v } } @@ -703,8 +703,8 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data // Always update tags to ensure they're in sync with spec desiredTags := map[string]string{"ManagedBy": "database-user-operator"} - if db.Spec.AWSSecretsManager != nil { - for k, v := range db.Spec.AWSSecretsManager.Tags { + if db.Spec.SecretBackend.AWS != nil { + for k, v := range db.Spec.SecretBackend.AWS.Tags { desiredTags[k] = v } } @@ -980,37 +980,37 @@ func (r *DatabaseReconciler) getConnectionString(ctx context.Context, db *databa } // Check which source is configured - if db.Spec.ConnectionStringSecretRef != nil { + if db.Spec.ConnectionString.Kubernetes != nil { logger.Info("Using Kubernetes Secret for admin connection string", "database", db.Spec.DatabaseName, - "secretName", db.Spec.ConnectionStringSecretRef.Name) + "secretName", db.Spec.ConnectionString.Kubernetes.Name) return r.getConnectionStringFromK8sSecret(ctx, db) } // Must be AWS secret (already validated) logger.Info("Using AWS Secrets Manager for admin connection string", "database", db.Spec.DatabaseName, - "secretName", db.Spec.ConnectionStringAWSSecretRef.SecretName, - "region", db.Spec.ConnectionStringAWSSecretRef.Region) + "secretName", db.Spec.ConnectionString.AWS.SecretName, + "region", db.Spec.ConnectionString.AWS.Region) return r.getConnectionStringFromAWSSecret(ctx, db) } func (r *DatabaseReconciler) getConnectionStringFromK8sSecret(ctx context.Context, db *databasev1alpha1.Database) (string, error) { secret := &corev1.Secret{} - if err := r.Get(ctx, client.ObjectKey{Name: db.Spec.ConnectionStringSecretRef.Name, Namespace: db.Namespace}, secret); err != nil { + if err := r.Get(ctx, client.ObjectKey{Name: db.Spec.ConnectionString.Kubernetes.Name, Namespace: db.Namespace}, secret); err != nil { return "", err } - key := getSecretKeyOrDefault(db.Spec.ConnectionStringSecretRef) + key := connectionStringKeyDefault(db.Spec.ConnectionString.Kubernetes.Key) connectionString := string(secret.Data[key]) if connectionString == "" { - return "", fmt.Errorf("connection string is empty in secret %s key %s", db.Spec.ConnectionStringSecretRef.Name, key) + return "", fmt.Errorf("connection string is empty in secret %s key %s", db.Spec.ConnectionString.Kubernetes.Name, key) } return connectionString, nil } func (r *DatabaseReconciler) getConnectionStringFromAWSSecret(ctx context.Context, db *databasev1alpha1.Database) (string, error) { logger := log.FromContext(ctx) - awsRef := db.Spec.ConnectionStringAWSSecretRef + awsRef := db.Spec.ConnectionString.AWS // Validate region if err := secrets.ValidateRegion(awsRef.Region); err != nil { @@ -1064,23 +1064,24 @@ func (r *DatabaseReconciler) getConnectionStringFromAWSSecret(ctx context.Contex return secretValue, nil } -// validateConnectionSource validates that only one connection string source is configured +// validateConnectionSource validates that exactly one connection string source is configured. func validateConnectionSource(db *databasev1alpha1.Database) error { - if db.Spec.ConnectionStringSecretRef != nil && db.Spec.ConnectionStringAWSSecretRef != nil { - return fmt.Errorf("both ConnectionStringSecretRef and ConnectionStringAWSSecretRef are specified, only one is allowed") + cs := db.Spec.ConnectionString + if cs.Kubernetes != nil && cs.AWS != nil { + return fmt.Errorf("both connectionString.kubernetes and connectionString.aws are specified, only one is allowed") } - if db.Spec.ConnectionStringSecretRef == nil && db.Spec.ConnectionStringAWSSecretRef == nil { - return fmt.Errorf("neither ConnectionStringSecretRef nor ConnectionStringAWSSecretRef is specified") + if cs.Kubernetes == nil && cs.AWS == nil { + return fmt.Errorf("neither connectionString.kubernetes nor connectionString.aws is specified") } return nil } -// getSecretKeyOrDefault returns the secret key from the reference, or the default key if not specified -func getSecretKeyOrDefault(ref *databasev1alpha1.SecretKeyReference) string { - if ref == nil || ref.Key == "" { +// connectionStringKeyDefault returns the secret key (or "connectionString" by default). +func connectionStringKeyDefault(key string) string { + if key == "" { return "connectionString" } - return ref.Key + return key } // getUsernameOrDefault returns the username from the spec, or the database name if not specified @@ -1161,11 +1162,11 @@ func getTagsToAdd(current, desired map[string]string) map[string]string { // getRegion determines the AWS region from the Database spec // Priority: spec.awsSecretsManager.region > spec.connectionStringAWSSecretRef.region > empty (AWS SDK default) func (r *DatabaseReconciler) getRegion(db *databasev1alpha1.Database) string { - if db.Spec.AWSSecretsManager != nil && db.Spec.AWSSecretsManager.Region != "" { - return db.Spec.AWSSecretsManager.Region + if db.Spec.SecretBackend.AWS != nil && db.Spec.SecretBackend.AWS.Region != "" { + return db.Spec.SecretBackend.AWS.Region } - if db.Spec.ConnectionStringAWSSecretRef != nil && db.Spec.ConnectionStringAWSSecretRef.Region != "" { - return db.Spec.ConnectionStringAWSSecretRef.Region + if db.Spec.ConnectionString.AWS != nil && db.Spec.ConnectionString.AWS.Region != "" { + return db.Spec.ConnectionString.AWS.Region } return "" // Empty string means use AWS SDK default } diff --git a/internal/controller/database_controller_unit_test.go b/internal/controller/database_controller_unit_test.go index a952f04..306cbe4 100644 --- a/internal/controller/database_controller_unit_test.go +++ b/internal/controller/database_controller_unit_test.go @@ -25,27 +25,33 @@ func TestGetRegion(t *testing.T) { want string }{ { - name: "awsSecretsManager region takes priority", + name: "secretBackend.aws region takes priority", db: &databasev1alpha1.Database{ Spec: databasev1alpha1.DatabaseSpec{ - AWSSecretsManager: &databasev1alpha1.AWSSecretsManagerConfig{ - Region: "us-west-1", + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-west-1", + }, }, - ConnectionStringAWSSecretRef: &databasev1alpha1.AWSSecretReference{ - SecretName: "test-secret", - Region: "us-east-1", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + AWS: &databasev1alpha1.AWSConnectionStringRef{ + SecretName: "test-secret", + Region: "us-east-1", + }, }, }, }, want: "us-west-1", }, { - name: "connectionStringAWSSecretRef region when no awsSecretsManager", + name: "connectionString.aws region when no secretBackend.aws", db: &databasev1alpha1.Database{ Spec: databasev1alpha1.DatabaseSpec{ - ConnectionStringAWSSecretRef: &databasev1alpha1.AWSSecretReference{ - SecretName: "test-secret", - Region: "ap-southeast-1", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + AWS: &databasev1alpha1.AWSConnectionStringRef{ + SecretName: "test-secret", + Region: "ap-southeast-1", + }, }, }, }, @@ -238,8 +244,10 @@ func TestValidateConnectionSource(t *testing.T) { name: "valid - only k8s secret", db: &databasev1alpha1.Database{ Spec: databasev1alpha1.DatabaseSpec{ - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "my-secret", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "my-secret", + }, }, }, }, @@ -249,9 +257,11 @@ func TestValidateConnectionSource(t *testing.T) { name: "valid - only AWS secret", db: &databasev1alpha1.Database{ Spec: databasev1alpha1.DatabaseSpec{ - ConnectionStringAWSSecretRef: &databasev1alpha1.AWSSecretReference{ - SecretName: "my-aws-secret", - Region: "us-east-1", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + AWS: &databasev1alpha1.AWSConnectionStringRef{ + SecretName: "my-aws-secret", + Region: "us-east-1", + }, }, }, }, @@ -261,17 +271,19 @@ func TestValidateConnectionSource(t *testing.T) { name: "invalid - both configured", db: &databasev1alpha1.Database{ Spec: databasev1alpha1.DatabaseSpec{ - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "my-secret", - }, - ConnectionStringAWSSecretRef: &databasev1alpha1.AWSSecretReference{ - SecretName: "my-aws-secret", - Region: "us-east-1", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "my-secret", + }, + AWS: &databasev1alpha1.AWSConnectionStringRef{ + SecretName: "my-aws-secret", + Region: "us-east-1", + }, }, }, }, wantErr: true, - errMsg: "both ConnectionStringSecretRef and ConnectionStringAWSSecretRef are specified", + errMsg: "both connectionString.kubernetes and connectionString.aws are specified", }, { name: "invalid - neither configured", @@ -279,7 +291,7 @@ func TestValidateConnectionSource(t *testing.T) { Spec: databasev1alpha1.DatabaseSpec{}, }, wantErr: true, - errMsg: "neither ConnectionStringSecretRef nor ConnectionStringAWSSecretRef is specified", + errMsg: "neither connectionString.kubernetes nor connectionString.aws is specified", }, } @@ -298,40 +310,29 @@ func TestValidateConnectionSource(t *testing.T) { } } -func TestGetSecretKeyOrDefault(t *testing.T) { +func TestConnectionStringKeyDefault(t *testing.T) { tests := []struct { name string - ref *databasev1alpha1.SecretKeyReference + key string want string }{ - { - name: "nil reference returns default", - ref: nil, - want: "connectionString", - }, { name: "empty key returns default", - ref: &databasev1alpha1.SecretKeyReference{ - Name: "my-secret", - Key: "", - }, + key: "", want: "connectionString", }, { - name: "custom key", - ref: &databasev1alpha1.SecretKeyReference{ - Name: "my-secret", - Key: "customKey", - }, + name: "custom key returns it unchanged", + key: "customKey", want: "customKey", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getSecretKeyOrDefault(tt.ref) + got := connectionStringKeyDefault(tt.key) if got != tt.want { - t.Errorf("getSecretKeyOrDefault() = %v, want %v", got, tt.want) + t.Errorf("connectionStringKeyDefault(%q) = %v, want %v", tt.key, got, tt.want) } }) } diff --git a/internal/secrets/factory.go b/internal/secrets/factory.go index ec8164d..8076f74 100644 --- a/internal/secrets/factory.go +++ b/internal/secrets/factory.go @@ -16,24 +16,47 @@ import ( // ErrNoBackendConfigured is returned when a Database resource doesn't // specify any of the supported destination backends. -var ErrNoBackendConfigured = errors.New("no destination backend configured: set spec.awsSecretsManager (later: spec.kubernetesSecret, spec.infisical)") +var ErrNoBackendConfigured = errors.New("spec.secretBackend has no backend configured: set one of aws, kubernetes, infisical") + +// ErrMultipleBackendsConfigured is returned when a Database resource +// specifies more than one destination backend simultaneously. +var ErrMultipleBackendsConfigured = errors.New("spec.secretBackend has multiple backends configured: set exactly one of aws, kubernetes, infisical") // NewBackend selects and constructs a Backend implementation based on -// which spec field is populated on the Database CR. Region resolution and -// other backend-specific defaulting happens in the implementation -// constructors. +// which spec.secretBackend.* field is populated. Returns +// ErrMultipleBackendsConfigured / ErrNoBackendConfigured if zero or +// more than one backend is set. func NewBackend(ctx context.Context, db *databasev1alpha1.Database) (Backend, error) { + sb := db.Spec.SecretBackend + + count := 0 + if sb.AWS != nil { + count++ + } + if sb.Kubernetes != nil { + count++ + } + if sb.Infisical != nil { + count++ + } + if count == 0 { + return nil, ErrNoBackendConfigured + } + if count > 1 { + return nil, ErrMultipleBackendsConfigured + } + switch { - case db.Spec.AWSSecretsManager != nil: - region := db.Spec.AWSSecretsManager.Region - if err := ValidateRegion(region); err != nil { + case sb.AWS != nil: + if err := ValidateRegion(sb.AWS.Region); err != nil { return nil, err } - return NewAWSSecretsManagerClient(ctx, region) + return NewAWSSecretsManagerClient(ctx, sb.AWS.Region) - // Future: - // case db.Spec.KubernetesSecret != nil: return NewKubernetesBackend(...) - // case db.Spec.Infisical != nil: return NewInfisicalBackend(...) + // Phase 2: + // case sb.Kubernetes != nil: return NewKubernetesBackend(...) + // Phase 3: + // case sb.Infisical != nil: return NewInfisicalBackend(...) default: return nil, ErrNoBackendConfigured From aa67cf7c2ecd2cb3bef32808ad42e79364a707a8 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 30 Apr 2026 11:14:21 +0200 Subject: [PATCH 05/13] feat(secrets): add Kubernetes Secret backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/secrets/kubernetes.go implements the Backend interface against controller-runtime's client.Client, storing the DatabaseSecret JSON blob under a single 'credentials' key in a Kubernetes Secret. Shape matches the AWS Secrets Manager backend so consumers can decode the same JSON regardless of which backend is configured. - Locator format: / - Description lands in the database.opzkit.io/description annotation (Kubernetes Secrets have no native description field) - Tags are mapped onto Secret labels (caller is responsible for ensuring values pass Kubernetes label validation) - Delete is idempotent on missing secrets (matches AWS impl) - Create overwrites an existing Secret to mirror AWS's restore-from-soft-deletion behaviour - forceDelete is ignored — Kubernetes Secrets have no soft-delete Wired into secrets.NewBackend via the new spec.secretBackend.kubernetes case. The factory signature gains a client.Client parameter (used by the K8s backend; ignored by AWS and Infisical). Tests cover create/get/update/delete/exists/syncTags via controller-runtime's fake client. Also adds the requested AWSRegionFromARN unit test. The controller still calls AWSSecretsManagerClient directly for now; the next commit refactors it to use the Backend interface so spec.secretBackend.kubernetes works end-to-end. --- internal/secrets/aws_backend_adapter_test.go | 58 +++++ internal/secrets/factory.go | 20 +- internal/secrets/kubernetes.go | 215 ++++++++++++++++ internal/secrets/kubernetes_test.go | 244 +++++++++++++++++++ 4 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 internal/secrets/aws_backend_adapter_test.go create mode 100644 internal/secrets/kubernetes.go create mode 100644 internal/secrets/kubernetes_test.go diff --git a/internal/secrets/aws_backend_adapter_test.go b/internal/secrets/aws_backend_adapter_test.go new file mode 100644 index 0000000..b9d8a7e --- /dev/null +++ b/internal/secrets/aws_backend_adapter_test.go @@ -0,0 +1,58 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import "testing" + +func TestAWSRegionFromARN(t *testing.T) { + tests := []struct { + name string + arn string + want string + }{ + { + name: "secrets manager ARN", + arn: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds/postgres/myapp-AbCdEf", + want: "eu-west-1", + }, + { + name: "us-gov ARN", + arn: "arn:aws-us-gov:secretsmanager:us-gov-west-1:123456789012:secret:foo-AbCdEf", + want: "us-gov-west-1", + }, + { + name: "non-AWS locator (kubernetes namespace/name)", + arn: "default/myapp-credentials", + want: "", + }, + { + name: "empty string", + arn: "", + want: "", + }, + { + name: "malformed ARN with too few parts", + arn: "arn:aws:secretsmanager", + want: "", + }, + { + name: "non-arn prefix", + arn: "not-an-arn:aws:secretsmanager:eu-west-1:123:secret:foo", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AWSRegionFromARN(tt.arn) + if got != tt.want { + t.Errorf("AWSRegionFromARN(%q) = %q, want %q", tt.arn, got, tt.want) + } + }) + } +} diff --git a/internal/secrets/factory.go b/internal/secrets/factory.go index 8076f74..47acaa9 100644 --- a/internal/secrets/factory.go +++ b/internal/secrets/factory.go @@ -11,6 +11,8 @@ import ( "context" "errors" + "sigs.k8s.io/controller-runtime/pkg/client" + databasev1alpha1 "opzkit/database-user-operator/api/v1alpha1" ) @@ -26,7 +28,11 @@ var ErrMultipleBackendsConfigured = errors.New("spec.secretBackend has multiple // which spec.secretBackend.* field is populated. Returns // ErrMultipleBackendsConfigured / ErrNoBackendConfigured if zero or // more than one backend is set. -func NewBackend(ctx context.Context, db *databasev1alpha1.Database) (Backend, error) { +// +// k8sClient is used by the Kubernetes Secret backend to read/write +// Secrets in the cluster; the AWS backend ignores it. It is required +// to be non-nil when spec.secretBackend.kubernetes is set. +func NewBackend(ctx context.Context, db *databasev1alpha1.Database, k8sClient client.Client) (Backend, error) { sb := db.Spec.SecretBackend count := 0 @@ -53,8 +59,16 @@ func NewBackend(ctx context.Context, db *databasev1alpha1.Database) (Backend, er } return NewAWSSecretsManagerClient(ctx, sb.AWS.Region) - // Phase 2: - // case sb.Kubernetes != nil: return NewKubernetesBackend(...) + case sb.Kubernetes != nil: + if k8sClient == nil { + return nil, errors.New("kubernetes secret backend requires a non-nil k8s client") + } + namespace := sb.Kubernetes.Namespace + if namespace == "" { + namespace = db.Namespace + } + return NewKubernetesBackend(k8sClient, namespace), nil + // Phase 3: // case sb.Infisical != nil: return NewInfisicalBackend(...) diff --git a/internal/secrets/kubernetes.go b/internal/secrets/kubernetes.go new file mode 100644 index 0000000..fbe3946 --- /dev/null +++ b/internal/secrets/kubernetes.go @@ -0,0 +1,215 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import ( + "context" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// CredentialsKey is the data key inside the Kubernetes Secret holding +// the DatabaseSecret JSON blob. We store the entire JSON under a single +// key so the on-the-wire shape matches AWS Secrets Manager and the +// Infisical backend. Apps can decode the JSON directly, or use +// External Secrets / a sidecar to project individual fields. +const CredentialsKey = "credentials" + +// DescriptionAnnotation is where the human-readable secret description +// lands on a Kubernetes Secret (Secrets don't have a "description" field). +const DescriptionAnnotation = "database.opzkit.io/description" + +// KubernetesBackend stores generated database credentials as Kubernetes +// Secrets in the configured namespace. The full DatabaseSecret is +// JSON-encoded under the single data key "credentials", matching the +// shape of the AWS Secrets Manager backend. +type KubernetesBackend struct { + client client.Client + namespace string +} + +// NewKubernetesBackend constructs a KubernetesBackend that creates +// Secrets in `namespace`. +func NewKubernetesBackend(c client.Client, namespace string) *KubernetesBackend { + return &KubernetesBackend{client: c, namespace: namespace} +} + +// Compile-time check that *KubernetesBackend satisfies Backend. +var _ Backend = (*KubernetesBackend)(nil) + +// Exists implements Backend. +func (k *KubernetesBackend) Exists(ctx context.Context, name string) (bool, error) { + var s corev1.Secret + err := k.client.Get(ctx, k.key(name), &s) + if err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to check kubernetes secret existence: %w", err) + } + return true, nil +} + +// Get implements Backend. +func (k *KubernetesBackend) Get(ctx context.Context, name string) (*DatabaseSecret, error) { + var s corev1.Secret + err := k.client.Get(ctx, k.key(name), &s) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, &SecretNotFoundError{SecretName: k.locator(name), Err: err} + } + return nil, fmt.Errorf("failed to get kubernetes secret: %w", err) + } + raw, ok := s.Data[CredentialsKey] + if !ok { + return nil, fmt.Errorf("kubernetes secret %s missing %q key", k.locator(name), CredentialsKey) + } + var dbSecret DatabaseSecret + if err := json.Unmarshal(raw, &dbSecret); err != nil { + return nil, fmt.Errorf("failed to unmarshal kubernetes secret %s: %w", k.locator(name), err) + } + return &dbSecret, nil +} + +// Create implements Backend. If the Secret already exists, its data is +// overwritten — same restore-from-soft-deletion semantics the AWS +// implementation provides. +func (k *KubernetesBackend) Create(ctx context.Context, name, description string, secret *DatabaseSecret, tags map[string]string, template string) (string, string, error) { + payload, err := secret.ToJSONWithTemplate(template) + if err != nil { + return "", "", fmt.Errorf("failed to marshal database secret: %w", err) + } + + desired := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: k.namespace, + Labels: copyMap(tags), + Annotations: map[string]string{ + DescriptionAnnotation: description, + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + CredentialsKey: payload, + }, + } + + if err := k.client.Create(ctx, desired); err != nil { + if !apierrors.IsAlreadyExists(err) { + return "", "", fmt.Errorf("failed to create kubernetes secret: %w", err) + } + // Already exists — overwrite (mirrors AWS restore-on-create behaviour). + var existing corev1.Secret + if err := k.client.Get(ctx, k.key(name), &existing); err != nil { + return "", "", fmt.Errorf("failed to get existing kubernetes secret after AlreadyExists: %w", err) + } + existing.Labels = copyMap(tags) + if existing.Annotations == nil { + existing.Annotations = map[string]string{} + } + existing.Annotations[DescriptionAnnotation] = description + if existing.Data == nil { + existing.Data = map[string][]byte{} + } + existing.Data[CredentialsKey] = payload + if err := k.client.Update(ctx, &existing); err != nil { + return "", "", fmt.Errorf("failed to update existing kubernetes secret: %w", err) + } + return k.locator(name), existing.ResourceVersion, nil + } + + return k.locator(name), desired.ResourceVersion, nil +} + +// Update implements Backend. +func (k *KubernetesBackend) Update(ctx context.Context, name string, secret *DatabaseSecret, template string) (string, error) { + payload, err := secret.ToJSONWithTemplate(template) + if err != nil { + return "", fmt.Errorf("failed to marshal database secret: %w", err) + } + + var s corev1.Secret + if err := k.client.Get(ctx, k.key(name), &s); err != nil { + if apierrors.IsNotFound(err) { + return "", &SecretNotFoundError{SecretName: k.locator(name), Err: err} + } + return "", fmt.Errorf("failed to get kubernetes secret for update: %w", err) + } + + if s.Data == nil { + s.Data = map[string][]byte{} + } + s.Data[CredentialsKey] = payload + + if err := k.client.Update(ctx, &s); err != nil { + return "", fmt.Errorf("failed to update kubernetes secret: %w", err) + } + return s.ResourceVersion, nil +} + +// Delete implements Backend. forceDelete is ignored — Kubernetes Secrets +// don't have a soft-delete state. +func (k *KubernetesBackend) Delete(ctx context.Context, name string, forceDelete bool) error { + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespace}, + } + if err := k.client.Delete(ctx, s); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to delete kubernetes secret: %w", err) + } + return nil +} + +// Locator implements Backend. Returns "/". +func (k *KubernetesBackend) Locator(ctx context.Context, name string) (string, error) { + return k.locator(name), nil +} + +// SyncTags implements Backend by mapping tags onto Secret labels. +// Kubernetes label values have stricter validation than AWS tags +// (max 63 chars, restricted character set); callers must ensure their +// tag values conform — this method does not sanitize. +func (k *KubernetesBackend) SyncTags(ctx context.Context, name string, desired map[string]string) error { + var s corev1.Secret + if err := k.client.Get(ctx, k.key(name), &s); err != nil { + return fmt.Errorf("failed to get kubernetes secret for tag sync: %w", err) + } + s.Labels = copyMap(desired) + if err := k.client.Update(ctx, &s); err != nil { + return fmt.Errorf("failed to update kubernetes secret labels: %w", err) + } + return nil +} + +func (k *KubernetesBackend) key(name string) types.NamespacedName { + return types.NamespacedName{Namespace: k.namespace, Name: name} +} + +func (k *KubernetesBackend) locator(name string) string { + return k.namespace + "/" + name +} + +func copyMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/secrets/kubernetes_test.go b/internal/secrets/kubernetes_test.go new file mode 100644 index 0000000..17706a1 --- /dev/null +++ b/internal/secrets/kubernetes_test.go @@ -0,0 +1,244 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import ( + "context" + "encoding/json" + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newFakeClient(objs ...client.Object) client.Client { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + panic(err) + } + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() +} + +func sampleSecret() *DatabaseSecret { + return &DatabaseSecret{ + DBHost: "pg.example.com", + DBPort: 5432, + DBName: "myapp_db", + DBUsername: "myapp_user", + DBPassword: "s3cret", + DatabaseURL: "postgresql://myapp_user:s3cret@pg.example.com:5432/myapp_db", + Engine: "postgres", + } +} + +func TestKubernetesBackend_CreateAndGet(t *testing.T) { + ctx := context.Background() + c := newFakeClient() + b := NewKubernetesBackend(c, "test-ns") + + secret := sampleSecret() + tags := map[string]string{"app": "myapp", "env": "test"} + + locator, version, err := b.Create(ctx, "myapp-creds", "myapp credentials", secret, tags, "") + if err != nil { + t.Fatalf("Create() failed: %v", err) + } + if locator != "test-ns/myapp-creds" { + t.Errorf("locator = %q, want %q", locator, "test-ns/myapp-creds") + } + if version == "" { + t.Error("expected non-empty resource version") + } + + // Inspect the underlying Secret directly. + var raw corev1.Secret + if err := c.Get(ctx, types.NamespacedName{Namespace: "test-ns", Name: "myapp-creds"}, &raw); err != nil { + t.Fatalf("failed to fetch underlying Secret: %v", err) + } + if raw.Type != corev1.SecretTypeOpaque { + t.Errorf("Secret.Type = %q, want Opaque", raw.Type) + } + if raw.Annotations[DescriptionAnnotation] != "myapp credentials" { + t.Errorf("description annotation missing or wrong: %q", raw.Annotations[DescriptionAnnotation]) + } + if raw.Labels["app"] != "myapp" || raw.Labels["env"] != "test" { + t.Errorf("labels = %v, want app=myapp env=test", raw.Labels) + } + + // Round-trip the JSON payload. + got, err := b.Get(ctx, "myapp-creds") + if err != nil { + t.Fatalf("Get() failed: %v", err) + } + if got.DBPassword != "s3cret" || got.DBUsername != "myapp_user" { + t.Errorf("round-trip mismatch: got %+v", got) + } +} + +func TestKubernetesBackend_CreateOverwritesExisting(t *testing.T) { + ctx := context.Background() + pre := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "myapp-creds", Namespace: "ns"}, + Data: map[string][]byte{"stale": []byte("garbage")}, + } + c := newFakeClient(pre) + b := NewKubernetesBackend(c, "ns") + + if _, _, err := b.Create(ctx, "myapp-creds", "desc", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create() over existing failed: %v", err) + } + + var raw corev1.Secret + if err := c.Get(ctx, types.NamespacedName{Namespace: "ns", Name: "myapp-creds"}, &raw); err != nil { + t.Fatalf("Get failed: %v", err) + } + if _, ok := raw.Data[CredentialsKey]; !ok { + t.Error("credentials key missing after upsert") + } +} + +func TestKubernetesBackend_ExistsAndUpdate(t *testing.T) { + ctx := context.Background() + c := newFakeClient() + b := NewKubernetesBackend(c, "ns") + + exists, err := b.Exists(ctx, "missing") + if err != nil { + t.Fatalf("Exists() failed on missing: %v", err) + } + if exists { + t.Error("Exists() = true for missing secret") + } + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create() failed: %v", err) + } + + exists, err = b.Exists(ctx, "creds") + if err != nil { + t.Fatalf("Exists() failed after create: %v", err) + } + if !exists { + t.Error("Exists() = false after create") + } + + updated := sampleSecret() + updated.DBPassword = "new-password" + if _, err := b.Update(ctx, "creds", updated, ""); err != nil { + t.Fatalf("Update() failed: %v", err) + } + + got, err := b.Get(ctx, "creds") + if err != nil { + t.Fatalf("Get() failed: %v", err) + } + if got.DBPassword != "new-password" { + t.Errorf("password not updated: got %q", got.DBPassword) + } +} + +func TestKubernetesBackend_UpdateMissingReturnsTypedError(t *testing.T) { + ctx := context.Background() + b := NewKubernetesBackend(newFakeClient(), "ns") + + _, err := b.Update(ctx, "no-such", sampleSecret(), "") + var notFound *SecretNotFoundError + if !errors.As(err, ¬Found) { + t.Errorf("expected *SecretNotFoundError, got %T: %v", err, err) + } +} + +func TestKubernetesBackend_DeleteIsIdempotent(t *testing.T) { + ctx := context.Background() + c := newFakeClient() + b := NewKubernetesBackend(c, "ns") + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create() failed: %v", err) + } + if err := b.Delete(ctx, "creds", false); err != nil { + t.Fatalf("first Delete() failed: %v", err) + } + // Second delete on a missing secret should not error. + if err := b.Delete(ctx, "creds", false); err != nil { + t.Errorf("second Delete() returned error: %v", err) + } +} + +func TestKubernetesBackend_SyncTagsReplacesLabels(t *testing.T) { + ctx := context.Background() + c := newFakeClient() + b := NewKubernetesBackend(c, "ns") + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), map[string]string{"old": "1", "keep": "yes"}, ""); err != nil { + t.Fatalf("Create() failed: %v", err) + } + if err := b.SyncTags(ctx, "creds", map[string]string{"keep": "yes", "new": "2"}); err != nil { + t.Fatalf("SyncTags() failed: %v", err) + } + + var raw corev1.Secret + if err := c.Get(ctx, types.NamespacedName{Namespace: "ns", Name: "creds"}, &raw); err != nil { + t.Fatalf("Get failed: %v", err) + } + if _, stillThere := raw.Labels["old"]; stillThere { + t.Error("removed label is still present") + } + if raw.Labels["new"] != "2" || raw.Labels["keep"] != "yes" { + t.Errorf("labels = %v, want new=2 keep=yes", raw.Labels) + } +} + +func TestKubernetesBackend_GetMissingReturnsTypedError(t *testing.T) { + ctx := context.Background() + b := NewKubernetesBackend(newFakeClient(), "ns") + + _, err := b.Get(ctx, "no-such") + var notFound *SecretNotFoundError + if !errors.As(err, ¬Found) { + t.Errorf("expected *SecretNotFoundError, got %T: %v", err, err) + } +} + +func TestKubernetesBackend_PayloadRoundTrip(t *testing.T) { + ctx := context.Background() + c := newFakeClient() + b := NewKubernetesBackend(c, "ns") + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create() failed: %v", err) + } + + var raw corev1.Secret + if err := c.Get(ctx, types.NamespacedName{Namespace: "ns", Name: "creds"}, &raw); err != nil { + t.Fatalf("Get failed: %v", err) + } + var blob map[string]any + if err := json.Unmarshal(raw.Data[CredentialsKey], &blob); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + for _, key := range []string{"DB_HOST", "DB_PORT", "DB_NAME", "DB_USERNAME", "DB_PASSWORD", "POSTGRES_URL"} { + if _, ok := blob[key]; !ok { + t.Errorf("payload missing key %q (got %v)", key, blob) + } + } +} + +// Sanity check: not-found errors from the fake client unwrap correctly. +func TestApierrorsSanity(t *testing.T) { + err := apierrors.NewNotFound(corev1.Resource("secret"), "no-such") + if !apierrors.IsNotFound(err) { + t.Fatal("apierrors.NewNotFound did not produce IsNotFound err") + } +} From 704db8048e67c8ec849248180f5d6dd181ddc71b Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 30 Apr 2026 11:19:17 +0200 Subject: [PATCH 06/13] refactor(controller): route destination secret ops through Backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces direct AWSSecretsManagerClient calls in the destination secret create/update/delete/exists/get/sync-tags paths with the generic secrets.Backend interface (constructed via secrets.NewBackend). spec.secretBackend.kubernetes now works end-to-end against a Kubernetes Secret in the cluster; spec.secretBackend.aws continues to behave exactly as before (AWS adapter satisfies the interface). AWS-specific paths kept direct: - Cross-region migration in reconcileDatabase + secret reconciliation paths (oldRegionClient — only triggered when SecretBackend.AWS is set and the locator is an AWS ARN whose region differs from spec) - Source-side reading from AWS Secrets Manager via getConnectionStringFromAWSSecret (the connectionString.aws path) Removed dead helpers tagsEqual / getTagsToRemove / getTagsToAdd — each backend implements SyncTags internally. Their unit tests went with them. Status field rename: secretARN local var → secretLocator throughout the create/update flow, populated from backend.Locator() / backend return values. Build green, full test suite passes. --- internal/controller/database_controller.go | 333 +++++------------- .../database_controller_unit_test.go | 246 +------------ 2 files changed, 89 insertions(+), 490 deletions(-) diff --git a/internal/controller/database_controller.go b/internal/controller/database_controller.go index 13fae47..13d7822 100644 --- a/internal/controller/database_controller.go +++ b/internal/controller/database_controller.go @@ -220,25 +220,17 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database var password string - // If only updating secret format (user/db already exist), retrieve existing password from AWS + // If only updating secret format (user/db already exist), retrieve existing password from the configured backend. if needsSecretUpdate && db.Status.UserCreated && db.Status.DatabaseCreated && db.Status.SecretCreated { - region := r.getRegion(db) - - // Validate region - if err := secrets.ValidateRegion(region); err != nil { - return fmt.Errorf("invalid AWS region for password retrieval: %w", err) - } - - logger.Info("Retrieving existing password from AWS Secrets Manager for format migration", - "secretName", db.Status.ActualSecretName, - "region", region) + logger.Info("Retrieving existing password from secret backend for format migration", + "secretName", db.Status.ActualSecretName) - awsClient, err := secrets.NewAWSSecretsManagerClient(ctx, region) + backend, err := secrets.NewBackend(ctx, db, r.Client) if err != nil { - return fmt.Errorf("failed to create AWS client for password retrieval: %w", err) + return fmt.Errorf("failed to construct secret backend for password retrieval: %w", err) } - existingSecret, err := awsClient.GetSecret(ctx, db.Status.ActualSecretName) + existingSecret, err := backend.Get(ctx, db.Status.ActualSecretName) if err != nil { return fmt.Errorf("failed to retrieve existing secret for migration: %w", err) } @@ -261,28 +253,19 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database return fmt.Errorf("failed to check if database exists: %w", err) } - // Check if secret exists in AWS Secrets Manager + // Check if the destination secret already exists. var secretExists bool - var awsClient *secrets.AWSSecretsManagerClient region := r.getRegion(db) - // Validate region - if err := secrets.ValidateRegion(region); err != nil { - return fmt.Errorf("invalid AWS region: %w", err) - } - - awsClient, err = secrets.NewAWSSecretsManagerClient(ctx, region) + backend, err := secrets.NewBackend(ctx, db, r.Client) if err != nil { - return fmt.Errorf("failed to create AWS client: %w", err) + return fmt.Errorf("failed to construct secret backend: %w", err) } - // Get the actual resolved region from the AWS client - region = awsClient.GetRegion() - // Determine secret name secretName := getSecretNameOrDefault(db) - secretExists, err = awsClient.SecretExists(ctx, secretName) + secretExists, err = backend.Exists(ctx, secretName) if err != nil { return fmt.Errorf("failed to check if secret exists: %w", err) } @@ -302,7 +285,7 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database "secretName", secretName) // Retrieve password from secret for grant operations - existingSecret, err := awsClient.GetSecret(ctx, secretName) + existingSecret, err := backend.Get(ctx, secretName) if err != nil { return fmt.Errorf("failed to retrieve existing secret: %w", err) } @@ -311,46 +294,18 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database return fmt.Errorf("could not extract password from existing secret") } - // Always update tags to ensure they're in sync with spec + // Always update tags/labels to ensure they're in sync with spec desiredTags := map[string]string{"ManagedBy": "database-user-operator"} if db.Spec.SecretBackend.AWS != nil { for k, v := range db.Spec.SecretBackend.AWS.Tags { desiredTags[k] = v } } - - // Get existing tags to determine what needs to be removed - existingTags, err := awsClient.GetSecretTags(ctx, secretName) - if err != nil { - logger.Error(err, "Failed to get existing secret tags, will still attempt to update tags", - "secretName", secretName) - existingTags = map[string]string{} // Continue with empty set - } - - // Determine tags to remove (exist but not in desired) - var tagsToRemove []string - for existingKey := range existingTags { - if _, desired := desiredTags[existingKey]; !desired { - tagsToRemove = append(tagsToRemove, existingKey) - } - } - - // Remove unwanted tags - if len(tagsToRemove) > 0 { - logger.Info("Removing tags from secret in AWS Secrets Manager", - "secretName", secretName, - "tagsToRemove", tagsToRemove) - if err := awsClient.UntagSecret(ctx, secretName, tagsToRemove); err != nil { - return fmt.Errorf("failed to remove secret tags: %w", err) - } - } - - // Add or update desired tags - logger.Info("Updating secret tags in AWS Secrets Manager", + logger.Info("Syncing secret tags", "secretName", secretName, "desiredTags", desiredTags) - if err := awsClient.TagSecret(ctx, secretName, desiredTags); err != nil { - return fmt.Errorf("failed to update secret tags: %w", err) + if err := backend.SyncTags(ctx, secretName, desiredTags); err != nil { + return fmt.Errorf("failed to sync secret tags: %w", err) } // Update status @@ -532,26 +487,16 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data secretName := getSecretNameOrDefault(db) db.Status.ActualSecretName = secretName - // Determine region: use awsSecretsManager.region if set, otherwise use connectionStringAWSSecretRef.region + // AWS-specific: detect region change between previously-stored + // locator (an ARN that carries the region) and the new spec region. region := r.getRegion(db) - var regionSource string - if db.Spec.SecretBackend.AWS != nil && db.Spec.SecretBackend.AWS.Region != "" { - regionSource = "spec.awsSecretsManager.region" - } else if db.Spec.ConnectionString.AWS != nil && db.Spec.ConnectionString.AWS.Region != "" { - regionSource = "spec.connectionStringAWSSecretRef.region" - } else { - regionSource = "AWS SDK default (environment/instance metadata)" - } - - // Validate region - if err := secrets.ValidateRegion(region); err != nil { - return fmt.Errorf("invalid AWS region: %w", err) + if db.Spec.SecretBackend.AWS != nil { + if err := secrets.ValidateRegion(region); err != nil { + return fmt.Errorf("invalid AWS region: %w", err) + } } - - // Detect region changes by reading the previously-used region from - // the stored ARN locator (AWS-specific format). oldRegion := secrets.AWSRegionFromARN(db.Status.SecretLocator) - regionChanged := oldRegion != "" && oldRegion != region + regionChanged := db.Spec.SecretBackend.AWS != nil && oldRegion != "" && oldRegion != region if regionChanged { logger.Info("Region change detected - secret will be created in new region", "database", db.Spec.DatabaseName, @@ -562,40 +507,28 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data } if isMigration { - logger.Info("Migrating secret to new format in AWS Secrets Manager", + logger.Info("Migrating secret to new format", "database", db.Spec.DatabaseName, - "secretName", secretName, - "region", region, - "regionSource", regionSource) + "secretName", secretName) } else { - logger.Info("Storing credentials in AWS Secrets Manager", + logger.Info("Storing credentials in secret backend", "database", db.Spec.DatabaseName, "secretName", secretName, - "region", region, - "regionSource", regionSource, "username", username) } - awsClient, err := secrets.NewAWSSecretsManagerClient(ctx, region) + + backend, err := secrets.NewBackend(ctx, db, r.Client) if err != nil { - return fmt.Errorf("failed to create AWS Secrets Manager client for storing credentials (ensure pod has AWS permissions via IRSA, instance profile, or credentials): %w", err) + return fmt.Errorf("failed to construct secret backend: %w", err) } - // Get the actual resolved region from the AWS client - // This is important when region is "" (using AWS SDK default resolution) - region = awsClient.GetRegion() - logger.Info("AWS client created with resolved region", - "database", db.Spec.DatabaseName, - "resolvedRegion", region, - "regionSource", regionSource) - - // Check if secret exists in the target region - exists, err := awsClient.SecretExists(ctx, secretName) + exists, err := backend.Exists(ctx, secretName) if err != nil { return err } - // If region changed and secret doesn't exist in new region, check old region - if regionChanged && !exists && oldRegion != "" { + // If region changed and secret doesn't exist in new region, check old region (AWS-specific). + if regionChanged && !exists { logger.Info("Checking for secret in old region", "secretName", secretName, "oldRegion", oldRegion) @@ -619,20 +552,20 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data } } - var secretARN, versionID string + var secretLocator, versionID string createSecret := !exists if exists { if isMigration { - logger.Info("Updating existing secret with new format (v2) in AWS Secrets Manager", + logger.Info("Updating existing secret with new format (v2)", "database", db.Spec.DatabaseName, "secretName", secretName) } else { - logger.Info("Updating existing secret in AWS Secrets Manager", + logger.Info("Updating existing secret", "database", db.Spec.DatabaseName, "secretName", secretName) } - versionID, err = awsClient.UpdateSecretWithTemplate(ctx, secretName, secretValue, db.Spec.SecretTemplate) + versionID, err = backend.Update(ctx, secretName, secretValue, db.Spec.SecretTemplate) if err != nil { // Check if secret was deleted externally var notFoundErr *secrets.SecretNotFoundError @@ -641,7 +574,7 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data "secretName", secretName) createSecret = true } else { - // Check if secret is marked for deletion + // Check if secret is marked for deletion (AWS-only) var markedForDeletionErr *secrets.SecretMarkedForDeletionError if errors.As(err, &markedForDeletionErr) { logger.Info("Secret is marked for deletion in AWS Secrets Manager, will create new secret", @@ -654,22 +587,20 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data } if !createSecret { - secretARN, _ = awsClient.GetSecretARN(ctx, secretName) + secretLocator, _ = backend.Locator(ctx, secretName) if isMigration { - logger.Info("Secret migrated successfully to v2 format in AWS Secrets Manager", + logger.Info("Secret migrated successfully to v2 format", "database", db.Spec.DatabaseName, "secretName", secretName, - "secretARN", secretARN, + "secretLocator", secretLocator, "versionID", versionID, - "region", region, "format", "v2 (DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD, POSTGRES_URL)") } else { - logger.Info("Secret updated successfully in AWS Secrets Manager", + logger.Info("Secret updated successfully", "database", db.Spec.DatabaseName, "secretName", secretName, - "secretARN", secretARN, - "versionID", versionID, - "region", region) + "secretLocator", secretLocator, + "versionID", versionID) } } } @@ -685,92 +616,60 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data tags[k] = v } } - logger.Info("Creating new secret in AWS Secrets Manager", + logger.Info("Creating new secret in backend", "database", db.Spec.DatabaseName, "secretName", secretName, "description", description) - secretARN, versionID, err = awsClient.CreateSecretWithTemplate(ctx, secretName, description, secretValue, tags, db.Spec.SecretTemplate) + secretLocator, versionID, err = backend.Create(ctx, secretName, description, secretValue, tags, db.Spec.SecretTemplate) if err != nil { return err } - logger.Info("Secret created successfully in AWS Secrets Manager", + logger.Info("Secret created successfully", "database", db.Spec.DatabaseName, "secretName", secretName, - "secretARN", secretARN, - "versionID", versionID, - "region", region) + "secretLocator", secretLocator, + "versionID", versionID) } - // Always update tags to ensure they're in sync with spec + // Always sync tags/labels with spec desiredTags := map[string]string{"ManagedBy": "database-user-operator"} if db.Spec.SecretBackend.AWS != nil { for k, v := range db.Spec.SecretBackend.AWS.Tags { desiredTags[k] = v } } - - // Get existing tags to determine what needs to be removed - existingTags, err := awsClient.GetSecretTags(ctx, secretName) - if err != nil { - logger.Error(err, "Failed to get existing secret tags, will still attempt to update tags", - "secretName", secretName) - existingTags = map[string]string{} // Continue with empty set - } - - // Determine tags to remove (exist but not in desired) - var tagsToRemove []string - for existingKey := range existingTags { - if _, desired := desiredTags[existingKey]; !desired { - tagsToRemove = append(tagsToRemove, existingKey) - } - } - - // Remove unwanted tags - if len(tagsToRemove) > 0 { - logger.Info("Removing tags from secret in AWS Secrets Manager", - "secretName", secretName, - "tagsToRemove", tagsToRemove) - if err := awsClient.UntagSecret(ctx, secretName, tagsToRemove); err != nil { - return fmt.Errorf("failed to remove secret tags: %w", err) - } - } - - // Add or update desired tags - logger.Info("Updating secret tags in AWS Secrets Manager", + logger.Info("Syncing secret tags", "secretName", secretName, "desiredTags", desiredTags) - if err := awsClient.TagSecret(ctx, secretName, desiredTags); err != nil { - return fmt.Errorf("failed to update secret tags: %w", err) + if err := backend.SyncTags(ctx, secretName, desiredTags); err != nil { + return fmt.Errorf("failed to sync secret tags: %w", err) } - // If region changed, delete secret from old region ONLY after successful creation in new region - // Only delete if we have a valid secretARN (confirming successful creation in new region) - if regionChanged && oldRegion != "" && secretARN != "" { + // AWS-specific: if region changed, delete the secret from the old + // region ONLY after a successful create/update in the new region. + if regionChanged && secretLocator != "" { logger.Info("Deleting secret from old region after successful migration", "secretName", secretName, "oldRegion", oldRegion, "newRegion", region, - "newSecretARN", secretARN) + "newSecretLocator", secretLocator) oldRegionClient, err := secrets.NewAWSSecretsManagerClient(ctx, oldRegion) if err != nil { logger.Error(err, "Failed to create AWS client for old region to delete secret", "oldRegion", oldRegion, "warning", "Secret may still exist in old region and should be manually deleted") + } else if err := oldRegionClient.DeleteSecret(ctx, secretName, true); err != nil { + logger.Error(err, "Failed to delete secret from old region", + "oldRegion", oldRegion, + "secretName", secretName, + "warning", "Secret may still exist in old region and should be manually deleted") } else { - // Use force delete to remove immediately without recovery window - if err := oldRegionClient.DeleteSecret(ctx, secretName, true); err != nil { - logger.Error(err, "Failed to delete secret from old region", - "oldRegion", oldRegion, - "secretName", secretName, - "warning", "Secret may still exist in old region and should be manually deleted") - } else { - logger.Info("Successfully deleted secret from old region", - "secretName", secretName, - "oldRegion", oldRegion) - } + logger.Info("Successfully deleted secret from old region", + "secretName", secretName, + "oldRegion", oldRegion) } - } else if regionChanged && oldRegion != "" && secretARN == "" { + } else if regionChanged && secretLocator == "" { logger.Error(nil, "Region changed but secret not successfully created in new region - keeping secret in old region", "oldRegion", oldRegion, "newRegion", region, @@ -778,7 +677,7 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data } db.Status.SecretCreated = true - db.Status.SecretLocator = secretARN + db.Status.SecretLocator = secretLocator db.Status.SecretVersion = versionID db.Status.SecretFormatVersion = "v2" db.Status.ConnectionInfo = databasev1alpha1.ConnectionInfo{ @@ -892,52 +791,28 @@ func (r *DatabaseReconciler) reconcileDelete(ctx context.Context, db *databasev1 } } - // Delete credentials from AWS Secrets Manager - // Try to delete even if status doesn't indicate creation, as the secret might exist - region := r.getRegion(db) - - // Validate region - if err := secrets.ValidateRegion(region); err != nil { - logger.Error(err, "Invalid AWS region for secret deletion") - cleanupErrors = append(cleanupErrors, fmt.Errorf("invalid AWS region %s: %w", region, err)) + // Delete credentials from the configured backend. + // Try to delete even if status doesn't indicate creation, as the secret might exist. + backend, err := secrets.NewBackend(ctx, db, r.Client) + if err != nil { + logger.Error(err, "Failed to construct secret backend for deletion") + cleanupErrors = append(cleanupErrors, fmt.Errorf("failed to construct secret backend: %w", err)) } else { - awsClient, err := secrets.NewAWSSecretsManagerClient(ctx, region) - if err != nil { - logger.Error(err, "Failed to create AWS Secrets Manager client for deletion", - "region", region) - cleanupErrors = append(cleanupErrors, fmt.Errorf("failed to create AWS client: %w", err)) - } else { - // Get the actual resolved region - region = awsClient.GetRegion() + secretName := db.Status.ActualSecretName + if secretName == "" { + secretName = getSecretNameOrDefault(db) + } - // Determine the secret name to delete - secretName := db.Status.ActualSecretName - if secretName == "" { - secretName = getSecretNameOrDefault(db) - } + logger.Info("Deleting secret from backend", "secretName", secretName) - logger.Info("Deleting secret from AWS Secrets Manager", - "secretName", secretName, - "region", region) - - if err := awsClient.DeleteSecret(ctx, secretName, true); err != nil { - // Ignore ResourceNotFoundException - secret doesn't exist, which is fine - if !isAWSResourceNotFoundError(err) { - logger.Error(err, "Failed to delete secret from AWS Secrets Manager", - "secretName", secretName, - "region", region) - cleanupErrors = append(cleanupErrors, fmt.Errorf("failed to delete secret %s: %w", secretName, err)) - } else { - logger.Info("Secret does not exist, skipping deletion", - "secretName", secretName, - "region", region) - } - } else { - secretDeleted = true - logger.Info("Secret deleted successfully from AWS Secrets Manager", - "secretName", secretName, - "region", region) - } + if err := backend.Delete(ctx, secretName, true); err != nil { + logger.Error(err, "Failed to delete secret from backend", + "secretName", secretName) + cleanupErrors = append(cleanupErrors, fmt.Errorf("failed to delete secret %s: %w", secretName, err)) + } else { + secretDeleted = true + logger.Info("Secret deleted successfully from backend", + "secretName", secretName) } } @@ -1123,44 +998,8 @@ func getSecretNameOrDefault(db *databasev1alpha1.Database) string { return fmt.Sprintf("rds/%s/%s", db.Spec.Engine, db.Spec.DatabaseName) } -// tagsEqual compares two tag maps and returns true if they are equal -func tagsEqual(a, b map[string]string) bool { - if len(a) != len(b) { - return false - } - for k, v := range a { - if b[k] != v { - return false - } - } - return true -} - -// getTagsToRemove returns tags that exist in current but not in desired -func getTagsToRemove(current, desired map[string]string) []string { - var toRemove []string - for key := range current { - if _, exists := desired[key]; !exists { - toRemove = append(toRemove, key) - } - } - return toRemove -} - -// getTagsToAdd returns tags that are in desired but missing or different in current -func getTagsToAdd(current, desired map[string]string) map[string]string { - toAdd := make(map[string]string) - for key, desiredValue := range desired { - currentValue, exists := current[key] - if !exists || currentValue != desiredValue { - toAdd[key] = desiredValue - } - } - return toAdd -} - -// getRegion determines the AWS region from the Database spec -// Priority: spec.awsSecretsManager.region > spec.connectionStringAWSSecretRef.region > empty (AWS SDK default) +// getRegion determines the AWS region from the Database spec. +// Priority: spec.secretBackend.aws.region > spec.connectionString.aws.region > empty (AWS SDK default). func (r *DatabaseReconciler) getRegion(db *databasev1alpha1.Database) string { if db.Spec.SecretBackend.AWS != nil && db.Spec.SecretBackend.AWS.Region != "" { return db.Spec.SecretBackend.AWS.Region diff --git a/internal/controller/database_controller_unit_test.go b/internal/controller/database_controller_unit_test.go index 306cbe4..2501e14 100644 --- a/internal/controller/database_controller_unit_test.go +++ b/internal/controller/database_controller_unit_test.go @@ -517,246 +517,6 @@ func TestGetSecretNameOrDefault(t *testing.T) { } } -func TestTagsEqual(t *testing.T) { - tests := []struct { - name string - a map[string]string - b map[string]string - want bool - }{ - { - name: "empty maps are equal", - a: map[string]string{}, - b: map[string]string{}, - want: true, - }, - { - name: "nil maps are equal", - a: nil, - b: nil, - want: true, - }, - { - name: "identical maps", - a: map[string]string{ - "env": "production", - "team": "platform", - }, - b: map[string]string{ - "env": "production", - "team": "platform", - }, - want: true, - }, - { - name: "different values", - a: map[string]string{ - "env": "production", - }, - b: map[string]string{ - "env": "staging", - }, - want: false, - }, - { - name: "different keys", - a: map[string]string{ - "env": "production", - }, - b: map[string]string{ - "environment": "production", - }, - want: false, - }, - { - name: "different lengths", - a: map[string]string{ - "env": "production", - "team": "platform", - }, - b: map[string]string{ - "env": "production", - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tagsEqual(tt.a, tt.b) - if got != tt.want { - t.Errorf("tagsEqual() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestGetTagsToRemove(t *testing.T) { - tests := []struct { - name string - current map[string]string - desired map[string]string - want []string - }{ - { - name: "remove tags not in desired", - current: map[string]string{ - "env": "production", - "team": "platform", - "version": "1.0", - }, - desired: map[string]string{ - "env": "production", - "team": "platform", - }, - want: []string{"version"}, - }, - { - name: "no tags to remove", - current: map[string]string{ - "env": "production", - "team": "platform", - }, - desired: map[string]string{ - "env": "production", - "team": "platform", - "version": "1.0", - }, - want: nil, - }, - { - name: "remove all tags", - current: map[string]string{ - "env": "production", - "team": "platform", - }, - desired: map[string]string{}, - want: []string{"env", "team"}, - }, - { - name: "empty current", - current: map[string]string{}, - desired: map[string]string{ - "env": "production", - }, - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := getTagsToRemove(tt.current, tt.desired) - if tt.want == nil { - if len(got) > 0 { - t.Errorf("getTagsToRemove() = %v, want nil or empty", got) - } - return - } - if len(got) != len(tt.want) { - t.Errorf("getTagsToRemove() returned %d tags, want %d", len(got), len(tt.want)) - return - } - // Convert to map for easier comparison (order doesn't matter) - gotMap := make(map[string]bool) - for _, tag := range got { - gotMap[tag] = true - } - for _, tag := range tt.want { - if !gotMap[tag] { - t.Errorf("getTagsToRemove() missing tag %q", tag) - } - } - }) - } -} - -func TestGetTagsToAdd(t *testing.T) { - tests := []struct { - name string - current map[string]string - desired map[string]string - want map[string]string - }{ - { - name: "add new tags", - current: map[string]string{ - "env": "production", - }, - desired: map[string]string{ - "env": "production", - "team": "platform", - }, - want: map[string]string{ - "team": "platform", - }, - }, - { - name: "update existing tag value", - current: map[string]string{ - "env": "staging", - }, - desired: map[string]string{ - "env": "production", - }, - want: map[string]string{ - "env": "production", - }, - }, - { - name: "no tags to add", - current: map[string]string{ - "env": "production", - "team": "platform", - }, - desired: map[string]string{ - "env": "production", - }, - want: map[string]string{}, - }, - { - name: "add all tags", - current: map[string]string{}, - desired: map[string]string{ - "env": "production", - "team": "platform", - }, - want: map[string]string{ - "env": "production", - "team": "platform", - }, - }, - { - name: "add and update mixed", - current: map[string]string{ - "env": "staging", - "version": "1.0", - }, - desired: map[string]string{ - "env": "production", - "team": "platform", - }, - want: map[string]string{ - "env": "production", - "team": "platform", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := getTagsToAdd(tt.current, tt.desired) - if len(got) != len(tt.want) { - t.Errorf("getTagsToAdd() returned %d tags, want %d\ngot: %v\nwant: %v", len(got), len(tt.want), got, tt.want) - return - } - for key, wantValue := range tt.want { - gotValue, exists := got[key] - if !exists { - t.Errorf("getTagsToAdd() missing tag %q", key) - } else if gotValue != wantValue { - t.Errorf("getTagsToAdd() tag %q = %v, want %v", key, gotValue, wantValue) - } - } - }) - } -} +// Tag-diff helpers were removed in favour of Backend.SyncTags, which +// each backend implements internally. The tests for tagsEqual / +// getTagsToRemove / getTagsToAdd were removed alongside the helpers. From 43453f6aa205e15bbc0764bc11dda83ad713df3b Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 30 Apr 2026 11:29:15 +0200 Subject: [PATCH 07/13] feat(secrets): add Infisical Cloud backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/secrets/infisical.go implements the Backend interface against Infisical (Cloud or self-hosted) via Universal Auth. The DatabaseSecret JSON blob is stored as a single Infisical secret value, matching the shape used by AWS Secrets Manager and the Kubernetes backend. - Authentication: Universal Auth (clientId + clientSecret) read from a Kubernetes Secret in the same namespace as the Database, referenced by spec.secretBackend.infisical.authSecretRef. - Locator format: infisical://// - Secret-key sanitisation: slashes in the supplied name (default rds//) are replaced with underscores so the result is a valid Infisical secret key. - Restore-on-create: if the secret already exists, Create overwrites it via Update — same semantics as AWS / Kubernetes. - SyncTags is a no-op: Infisical tags are first-class entities with their own UUIDs; mapping AWS-style key/value tags onto them is out of scope. - Versioning: Infisical's V3 secrets API doesn't expose per-secret version IDs, so Create/Update return an empty version string. Wired into secrets.NewBackend via the spec.secretBackend.infisical case. Factory reads the auth Secret using the K8s client. CRD: spec.secretBackend.infisical now exposes ProjectID (UUID) and Environment (slug) instead of the previously declared ProjectSlug / EnvironmentSlug — the Infisical V3 API requires the UUID for create/update/delete, slug only works on retrieve. Tests use an in-memory fake implementing the InfisicalSecretClient interface, covering create/get/update/delete/exists/sync-tags + key sanitisation and login-error propagation. Adds dependency github.com/infisical/go-sdk v0.7.1 (and its transitive google.golang.org/api / OpenTelemetry / grpc deps — substantial; see v0.2.0 release notes). Sample manifest under config/samples/. Closes Phase 3 of the multi-backend refactor. --- api/v1alpha1/database_types.go | 26 +- .../database_v1alpha1_database_infisical.yaml | 35 ++ go.mod | 29 +- go.sum | 187 +++++++--- .../crds/database.opzkit.io_databases.yaml | 20 +- internal/secrets/factory.go | 49 ++- internal/secrets/infisical.go | 330 ++++++++++++++++++ internal/secrets/infisical_test.go | 254 ++++++++++++++ 8 files changed, 862 insertions(+), 68 deletions(-) create mode 100644 config/samples/database_v1alpha1_database_infisical.yaml create mode 100644 internal/secrets/infisical.go create mode 100644 internal/secrets/infisical_test.go diff --git a/api/v1alpha1/database_types.go b/api/v1alpha1/database_types.go index 86ba3df..4354b8e 100644 --- a/api/v1alpha1/database_types.go +++ b/api/v1alpha1/database_types.go @@ -125,33 +125,35 @@ type KubernetesSecretBackend struct { Namespace string `json:"namespace,omitempty"` } -// InfisicalSecretBackend stores generated credentials in Infisical via -// Universal Auth. The clientId/clientSecret pair is read from a -// Kubernetes Secret referenced by AuthSecretRef; the API uses -// HostAPI / ProjectSlug / EnvironmentSlug / SecretsPath to address the -// target. +// InfisicalSecretBackend stores generated credentials in Infisical +// (Cloud or self-hosted) via Universal Auth. +// +// The Infisical V3 secrets API requires a project UUID for +// create/update/delete operations, so we expose ProjectID rather than +// the slug used elsewhere (e.g. the ESO ClusterSecretStore). Find the +// UUID in the Infisical UI under Project Settings. type InfisicalSecretBackend struct { // HostAPI is the Infisical API endpoint. Default: https://app.infisical.com // +optional // +kubebuilder:default="https://app.infisical.com" HostAPI string `json:"hostAPI,omitempty"` - // ProjectSlug is the Infisical project slug. + // ProjectID is the Infisical project UUID. // +kubebuilder:validation:Required - ProjectSlug string `json:"projectSlug"` + ProjectID string `json:"projectID"` - // EnvironmentSlug is the Infisical environment slug (e.g. dev, prod). + // Environment is the Infisical environment slug (e.g. dev, staging, prod). // +kubebuilder:validation:Required - EnvironmentSlug string `json:"environmentSlug"` + Environment string `json:"environment"` - // SecretsPath is the path inside the environment. Default: "/" + // SecretsPath is the folder path inside the environment. Default: "/" // +optional // +kubebuilder:default="/" SecretsPath string `json:"secretsPath,omitempty"` // AuthSecretRef references a Kubernetes Secret in the same namespace - // as the Database holding clientId/clientSecret keys for Infisical - // Universal Auth. + // as the Database holding `clientId` and `clientSecret` keys for + // Infisical Universal Auth. // +kubebuilder:validation:Required AuthSecretRef KubernetesSecretRef `json:"authSecretRef"` } diff --git a/config/samples/database_v1alpha1_database_infisical.yaml b/config/samples/database_v1alpha1_database_infisical.yaml new file mode 100644 index 0000000..763f70d --- /dev/null +++ b/config/samples/database_v1alpha1_database_infisical.yaml @@ -0,0 +1,35 @@ +# Bootstrap Secret holding the Infisical Universal Auth credentials. +# Create this once per namespace by hand (or via your secrets bootstrap +# tool) before applying the Database below: +# +# kubectl create secret generic infisical-universal-auth \ +# --namespace myapp \ +# --from-literal=clientId= \ +# --from-literal=clientSecret= +# +--- +apiVersion: database.opzkit.io/v1alpha1 +kind: Database +metadata: + name: myapp-database-infisical + namespace: myapp +spec: + engine: postgres + databaseName: myapp_db + + # Source of the admin DSN — read from a Secret in the cluster. + connectionString: + kubernetes: + name: postgres-admin + + # Generated user credentials are written to Infisical as a single + # secret whose value is the JSON-encoded credentials blob (matching + # the AWS / Kubernetes backend shape). + secretBackend: + infisical: + hostAPI: https://app.infisical.com # or https://eu.infisical.com / your self-hosted URL + projectID: 11111111-2222-3333-4444-555555555555 + environment: dev + secretsPath: / + authSecretRef: + name: infisical-universal-auth diff --git a/go.mod b/go.mod index c90cf80..1d39694 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 github.com/go-sql-driver/mysql v1.10.0 + github.com/infisical/go-sdk v0.7.1 github.com/lib/pq v1.12.3 github.com/onsi/ginkgo/v2 v2.28.3 github.com/onsi/gomega v1.40.0 @@ -20,6 +21,10 @@ require ( ) require ( + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.1.11 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect @@ -39,9 +44,11 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect @@ -57,26 +64,42 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-resty/resty/v2 v2.13.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/btree v1.1.3 // indirect + github.com/gofrs/flock v0.8.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.0 // indirect + github.com/rs/zerolog v1.26.1 // indirect + github.com/sony/gobreaker v0.5.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect @@ -87,6 +110,10 @@ require ( golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.44.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/api v0.267.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 4f44818..0e2e18d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= +cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= @@ -36,17 +44,26 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -57,8 +74,11 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= @@ -95,14 +115,19 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= +github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -110,12 +135,20 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/infisical/go-sdk v0.7.1 h1:26upmNiIuXJgZEQdH8ThLZ18EIGdg9ifMm+fBGXSmP0= +github.com/infisical/go-sdk v0.7.1/go.mod h1:yEfXF+3YDDXiJ9zzJUSzW6me6XXPPEDK52fSU6JfpCA= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -142,17 +175,16 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94= +github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -166,12 +198,22 @@ github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82 github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= +github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= +github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= +github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -184,6 +226,26 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -194,42 +256,100 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -239,36 +359,23 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= -k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= -k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= -k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= -k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= -k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= -k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= -k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= -sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/controller-runtime v0.24.0 h1:Ck6N2LdS8Lovy1o25BB4r1xjvLEKUl1s2o9kU+KWDE4= sigs.k8s.io/controller-runtime v0.24.0/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml index 3ab66d2..5a195e3 100644 --- a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml +++ b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml @@ -233,8 +233,8 @@ spec: authSecretRef: description: |- AuthSecretRef references a Kubernetes Secret in the same namespace - as the Database holding clientId/clientSecret keys for Infisical - Universal Auth. + as the Database holding `clientId` and `clientSecret` keys for + Infisical Universal Auth. properties: name: description: Name of the Secret. @@ -242,27 +242,27 @@ spec: required: - name type: object - environmentSlug: - description: EnvironmentSlug is the Infisical environment - slug (e.g. dev, prod). + environment: + description: Environment is the Infisical environment slug + (e.g. dev, staging, prod). type: string hostAPI: default: https://app.infisical.com description: 'HostAPI is the Infisical API endpoint. Default: https://app.infisical.com' type: string - projectSlug: - description: ProjectSlug is the Infisical project slug. + projectID: + description: ProjectID is the Infisical project UUID. type: string secretsPath: default: / - description: 'SecretsPath is the path inside the environment. + description: 'SecretsPath is the folder path inside the environment. Default: "/"' type: string required: - authSecretRef - - environmentSlug - - projectSlug + - environment + - projectID type: object kubernetes: description: Kubernetes stores credentials as a Kubernetes Secret. diff --git a/internal/secrets/factory.go b/internal/secrets/factory.go index 47acaa9..08df8a7 100644 --- a/internal/secrets/factory.go +++ b/internal/secrets/factory.go @@ -10,7 +10,10 @@ package secrets import ( "context" "errors" + "fmt" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" databasev1alpha1 "opzkit/database-user-operator/api/v1alpha1" @@ -24,14 +27,21 @@ var ErrNoBackendConfigured = errors.New("spec.secretBackend has no backend confi // specifies more than one destination backend simultaneously. var ErrMultipleBackendsConfigured = errors.New("spec.secretBackend has multiple backends configured: set exactly one of aws, kubernetes, infisical") +// Standard data keys for the Infisical Universal Auth bootstrap Secret. +const ( + InfisicalAuthClientIDKey = "clientId" + InfisicalAuthClientSecretKey = "clientSecret" +) + // NewBackend selects and constructs a Backend implementation based on // which spec.secretBackend.* field is populated. Returns // ErrMultipleBackendsConfigured / ErrNoBackendConfigured if zero or // more than one backend is set. // -// k8sClient is used by the Kubernetes Secret backend to read/write -// Secrets in the cluster; the AWS backend ignores it. It is required -// to be non-nil when spec.secretBackend.kubernetes is set. +// k8sClient is used by the Kubernetes Secret backend (to read/write +// Secrets) and by the Infisical backend (to read the Universal Auth +// bootstrap Secret). It must be non-nil when either of those backends +// is selected; the AWS backend ignores it. func NewBackend(ctx context.Context, db *databasev1alpha1.Database, k8sClient client.Client) (Backend, error) { sb := db.Spec.SecretBackend @@ -69,10 +79,39 @@ func NewBackend(ctx context.Context, db *databasev1alpha1.Database, k8sClient cl } return NewKubernetesBackend(k8sClient, namespace), nil - // Phase 3: - // case sb.Infisical != nil: return NewInfisicalBackend(...) + case sb.Infisical != nil: + if k8sClient == nil { + return nil, errors.New("infisical secret backend requires a non-nil k8s client (to read the universal-auth bootstrap Secret)") + } + auth, err := readInfisicalAuth(ctx, k8sClient, db.Namespace, sb.Infisical.AuthSecretRef.Name) + if err != nil { + return nil, err + } + return NewInfisicalBackend( + sb.Infisical.HostAPI, + sb.Infisical.ProjectID, + sb.Infisical.Environment, + sb.Infisical.SecretsPath, + auth, + ), nil default: return nil, ErrNoBackendConfigured } } + +// readInfisicalAuth reads the clientId/clientSecret pair from the +// Kubernetes Secret referenced by spec.secretBackend.infisical.authSecretRef. +// The Secret must live in the same namespace as the Database resource. +func readInfisicalAuth(ctx context.Context, k8sClient client.Client, namespace, name string) (InfisicalAuth, error) { + var s corev1.Secret + if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &s); err != nil { + return InfisicalAuth{}, fmt.Errorf("failed to read infisical auth secret %s/%s: %w", namespace, name, err) + } + clientID := string(s.Data[InfisicalAuthClientIDKey]) + clientSecret := string(s.Data[InfisicalAuthClientSecretKey]) + if clientID == "" || clientSecret == "" { + return InfisicalAuth{}, fmt.Errorf("infisical auth secret %s/%s missing %q or %q key", namespace, name, InfisicalAuthClientIDKey, InfisicalAuthClientSecretKey) + } + return InfisicalAuth{ClientID: clientID, ClientSecret: clientSecret}, nil +} diff --git a/internal/secrets/infisical.go b/internal/secrets/infisical.go new file mode 100644 index 0000000..608be32 --- /dev/null +++ b/internal/secrets/infisical.go @@ -0,0 +1,330 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + infisical "github.com/infisical/go-sdk" +) + +// InfisicalSecretClient is the subset of the Infisical SDK's secret-management +// surface that the InfisicalBackend uses. Defining it as an interface here +// lets tests swap in an in-memory fake. +type InfisicalSecretClient interface { + Create(opts infisical.CreateSecretOptions) (any, error) + Retrieve(opts infisical.RetrieveSecretOptions) (string, error) + Update(opts infisical.UpdateSecretOptions) (any, error) + Delete(opts infisical.DeleteSecretOptions) (any, error) +} + +// infisicalSDKClient adapts the real SDK client to InfisicalSecretClient. +// The SDK exposes methods that return concrete model types; our interface +// hides those so tests don't need to construct them. +type infisicalSDKClient struct { + inner infisical.SecretsInterface +} + +func (c *infisicalSDKClient) Create(opts infisical.CreateSecretOptions) (any, error) { + return c.inner.Create(opts) +} + +func (c *infisicalSDKClient) Retrieve(opts infisical.RetrieveSecretOptions) (string, error) { + s, err := c.inner.Retrieve(opts) + if err != nil { + return "", err + } + return s.SecretValue, nil +} + +func (c *infisicalSDKClient) Update(opts infisical.UpdateSecretOptions) (any, error) { + return c.inner.Update(opts) +} + +func (c *infisicalSDKClient) Delete(opts infisical.DeleteSecretOptions) (any, error) { + return c.inner.Delete(opts) +} + +// InfisicalAuth represents the Universal Auth credential pair used to +// authenticate against Infisical. The pair is sourced from a Kubernetes +// Secret in the cluster; reading it is the controller's responsibility, +// not this package's. +type InfisicalAuth struct { + ClientID string + ClientSecret string +} + +// InfisicalBackend stores generated database credentials in Infisical +// (Cloud or self-hosted) via Universal Auth. The DatabaseSecret JSON +// blob is stored as the value of a single secret keyed by a sanitised +// version of the supplied name (slashes replaced with underscores so +// the result is a valid Infisical secret key). +// +// Infisical's V3 secret API does not expose per-secret version IDs, so +// the version returned from Create/Update is always "". +// +// SyncTags is a no-op: Infisical tags are first-class entities with +// their own UUIDs and lifecycle, mapping AWS-style key/value tags onto +// them is non-trivial and out of scope for now. +type InfisicalBackend struct { + client InfisicalSecretClient + projectID string + environment string + secretsPath string + + auth InfisicalAuth + loginer func(InfisicalAuth) error + loginOnce sync.Once + loginErr error +} + +// NewInfisicalBackend constructs an InfisicalBackend backed by the real +// Infisical SDK. siteURL is typically "https://app.infisical.com" for +// Cloud, or your self-hosted URL. +func NewInfisicalBackend(siteURL, projectID, environment, secretsPath string, auth InfisicalAuth) *InfisicalBackend { + if siteURL == "" { + siteURL = "https://app.infisical.com" + } + if secretsPath == "" { + secretsPath = "/" + } + + sdk := infisical.NewInfisicalClient(context.Background(), infisical.Config{ + SiteUrl: siteURL, + AutoTokenRefresh: true, + }) + + return &InfisicalBackend{ + client: &infisicalSDKClient{inner: sdk.Secrets()}, + loginer: func(a InfisicalAuth) error { + _, err := sdk.Auth().UniversalAuthLogin(a.ClientID, a.ClientSecret) + return err + }, + projectID: projectID, + environment: environment, + secretsPath: secretsPath, + auth: auth, + } +} + +// newInfisicalBackendWithClient is used by tests to inject a fake client. +func newInfisicalBackendWithClient(client InfisicalSecretClient, projectID, environment, secretsPath string, loginer func(InfisicalAuth) error, auth InfisicalAuth) *InfisicalBackend { + if secretsPath == "" { + secretsPath = "/" + } + return &InfisicalBackend{ + client: client, + loginer: loginer, + projectID: projectID, + environment: environment, + secretsPath: secretsPath, + auth: auth, + } +} + +// Compile-time check that *InfisicalBackend satisfies Backend. +var _ Backend = (*InfisicalBackend)(nil) + +func (b *InfisicalBackend) login() error { + b.loginOnce.Do(func() { + if b.loginer == nil { + return + } + if err := b.loginer(b.auth); err != nil { + b.loginErr = fmt.Errorf("infisical universal auth login failed: %w", err) + } + }) + return b.loginErr +} + +// key normalises an arbitrary "secret name" (which may include slashes +// from the AWS-style rds// default) into a valid Infisical +// secret key. +func (b *InfisicalBackend) key(name string) string { + return strings.ReplaceAll(strings.Trim(name, "/"), "/", "_") +} + +// locator returns a stable backend-specific identifier suitable for +// status reporting. +func (b *InfisicalBackend) locator(name string) string { + path := b.secretsPath + if !strings.HasSuffix(path, "/") { + path += "/" + } + return fmt.Sprintf("infisical://%s/%s%s%s", b.projectID, b.environment, path, b.key(name)) +} + +// Exists implements Backend. +func (b *InfisicalBackend) Exists(ctx context.Context, name string) (bool, error) { + if err := b.login(); err != nil { + return false, err + } + _, err := b.client.Retrieve(infisical.RetrieveSecretOptions{ + ProjectID: b.projectID, + Environment: b.environment, + SecretKey: b.key(name), + SecretPath: b.secretsPath, + }) + if err != nil { + if isInfisicalNotFound(err) { + return false, nil + } + return false, fmt.Errorf("infisical retrieve failed: %w", err) + } + return true, nil +} + +// Get implements Backend. +func (b *InfisicalBackend) Get(ctx context.Context, name string) (*DatabaseSecret, error) { + if err := b.login(); err != nil { + return nil, err + } + value, err := b.client.Retrieve(infisical.RetrieveSecretOptions{ + ProjectID: b.projectID, + Environment: b.environment, + SecretKey: b.key(name), + SecretPath: b.secretsPath, + }) + if err != nil { + if isInfisicalNotFound(err) { + return nil, &SecretNotFoundError{SecretName: b.locator(name), Err: err} + } + return nil, fmt.Errorf("infisical retrieve failed: %w", err) + } + var dbSecret DatabaseSecret + if err := json.Unmarshal([]byte(value), &dbSecret); err != nil { + return nil, fmt.Errorf("failed to unmarshal infisical secret value at %s: %w", b.locator(name), err) + } + return &dbSecret, nil +} + +// Create implements Backend. If the secret already exists, its value is +// overwritten via Update — same restore-on-create semantics as AWS / K8s. +func (b *InfisicalBackend) Create(ctx context.Context, name, description string, secret *DatabaseSecret, tags map[string]string, template string) (string, string, error) { + if err := b.login(); err != nil { + return "", "", err + } + payload, err := secret.ToJSONWithTemplate(template) + if err != nil { + return "", "", fmt.Errorf("failed to marshal database secret: %w", err) + } + + _, err = b.client.Create(infisical.CreateSecretOptions{ + ProjectID: b.projectID, + Environment: b.environment, + SecretKey: b.key(name), + SecretValue: string(payload), + SecretPath: b.secretsPath, + SecretComment: description, + }) + if err != nil { + if !isInfisicalAlreadyExists(err) { + return "", "", fmt.Errorf("infisical create failed: %w", err) + } + // Already exists — overwrite via Update. + if _, uerr := b.client.Update(infisical.UpdateSecretOptions{ + ProjectID: b.projectID, + Environment: b.environment, + SecretKey: b.key(name), + NewSecretValue: string(payload), + SecretPath: b.secretsPath, + }); uerr != nil { + return "", "", fmt.Errorf("infisical create-then-update failed: %w", uerr) + } + } + + return b.locator(name), "", nil +} + +// Update implements Backend. +func (b *InfisicalBackend) Update(ctx context.Context, name string, secret *DatabaseSecret, template string) (string, error) { + if err := b.login(); err != nil { + return "", err + } + payload, err := secret.ToJSONWithTemplate(template) + if err != nil { + return "", fmt.Errorf("failed to marshal database secret: %w", err) + } + + _, err = b.client.Update(infisical.UpdateSecretOptions{ + ProjectID: b.projectID, + Environment: b.environment, + SecretKey: b.key(name), + NewSecretValue: string(payload), + SecretPath: b.secretsPath, + }) + if err != nil { + if isInfisicalNotFound(err) { + return "", &SecretNotFoundError{SecretName: b.locator(name), Err: err} + } + return "", fmt.Errorf("infisical update failed: %w", err) + } + return "", nil +} + +// Delete implements Backend. forceDelete is ignored — Infisical doesn't +// have a soft-delete state. +func (b *InfisicalBackend) Delete(ctx context.Context, name string, forceDelete bool) error { + if err := b.login(); err != nil { + return err + } + _, err := b.client.Delete(infisical.DeleteSecretOptions{ + ProjectID: b.projectID, + Environment: b.environment, + SecretKey: b.key(name), + SecretPath: b.secretsPath, + }) + if err != nil { + if isInfisicalNotFound(err) { + return nil + } + return fmt.Errorf("infisical delete failed: %w", err) + } + return nil +} + +// Locator implements Backend. +func (b *InfisicalBackend) Locator(ctx context.Context, name string) (string, error) { + return b.locator(name), nil +} + +// SyncTags implements Backend as a no-op. Infisical tags are first-class +// entities with their own UUIDs; mapping them from AWS-style key/value +// tags is out of scope. +func (b *InfisicalBackend) SyncTags(ctx context.Context, name string, desired map[string]string) error { + return nil +} + +// isInfisicalNotFound returns true if the SDK error indicates the secret +// doesn't exist. The SDK doesn't currently expose typed errors for this +// (as of v0.7.x), so we fall back to a substring match. +func isInfisicalNotFound(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "not found") || + strings.Contains(msg, "does not exist") || + strings.Contains(msg, "secretnotfound") +} + +// isInfisicalAlreadyExists returns true if the SDK error indicates the +// secret already exists. +func isInfisicalAlreadyExists(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "already exists") || + strings.Contains(msg, "duplicate") || + strings.Contains(msg, "secretalreadyexists") +} diff --git a/internal/secrets/infisical_test.go b/internal/secrets/infisical_test.go new file mode 100644 index 0000000..7345bc1 --- /dev/null +++ b/internal/secrets/infisical_test.go @@ -0,0 +1,254 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +package secrets + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + infisical "github.com/infisical/go-sdk" +) + +// fakeInfisicalClient is an in-memory implementation of the +// InfisicalSecretClient interface used for tests. +type fakeInfisicalClient struct { + store map[string]string // key: ||| +} + +func newFakeInfisicalClient() *fakeInfisicalClient { + return &fakeInfisicalClient{store: map[string]string{}} +} + +func (f *fakeInfisicalClient) k(projectID, env, path, key string) string { + return projectID + "|" + env + "|" + path + "|" + key +} + +func (f *fakeInfisicalClient) Create(opts infisical.CreateSecretOptions) (any, error) { + k := f.k(opts.ProjectID, opts.Environment, opts.SecretPath, opts.SecretKey) + if _, exists := f.store[k]; exists { + return nil, errors.New("secret already exists") + } + f.store[k] = opts.SecretValue + return nil, nil +} + +func (f *fakeInfisicalClient) Retrieve(opts infisical.RetrieveSecretOptions) (string, error) { + k := f.k(opts.ProjectID, opts.Environment, opts.SecretPath, opts.SecretKey) + value, ok := f.store[k] + if !ok { + return "", errors.New("secret not found") + } + return value, nil +} + +func (f *fakeInfisicalClient) Update(opts infisical.UpdateSecretOptions) (any, error) { + k := f.k(opts.ProjectID, opts.Environment, opts.SecretPath, opts.SecretKey) + if _, exists := f.store[k]; !exists { + return nil, errors.New("secret not found") + } + f.store[k] = opts.NewSecretValue + return nil, nil +} + +func (f *fakeInfisicalClient) Delete(opts infisical.DeleteSecretOptions) (any, error) { + k := f.k(opts.ProjectID, opts.Environment, opts.SecretPath, opts.SecretKey) + if _, exists := f.store[k]; !exists { + return nil, errors.New("secret not found") + } + delete(f.store, k) + return nil, nil +} + +func newTestBackend(client *fakeInfisicalClient) *InfisicalBackend { + return newInfisicalBackendWithClient( + client, + "proj-uuid", + "dev", + "/", + func(InfisicalAuth) error { return nil }, // login no-ops in tests + InfisicalAuth{ClientID: "test-id", ClientSecret: "test-secret"}, + ) +} + +func TestInfisicalBackend_CreateAndGet(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + loc, _, err := b.Create(ctx, "myapp_db", "creds", sampleSecret(), nil, "") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if !strings.HasPrefix(loc, "infisical://proj-uuid/dev/") || !strings.HasSuffix(loc, "myapp_db") { + t.Errorf("locator = %q", loc) + } + + got, err := b.Get(ctx, "myapp_db") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.DBPassword != "s3cret" { + t.Errorf("password mismatch: %+v", got) + } +} + +func TestInfisicalBackend_KeySanitisation(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + if _, _, err := b.Create(ctx, "rds/postgres/myapp_db", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Stored under sanitised key. + if _, ok := fc.store["proj-uuid|dev|/|rds_postgres_myapp_db"]; !ok { + t.Errorf("expected sanitised key in store, got %v", fc.store) + } +} + +func TestInfisicalBackend_CreateOverwritesExisting(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("first Create failed: %v", err) + } + + updated := sampleSecret() + updated.DBPassword = "new-pwd" + if _, _, err := b.Create(ctx, "creds", "", updated, nil, ""); err != nil { + t.Fatalf("second Create (overwrite) failed: %v", err) + } + + got, err := b.Get(ctx, "creds") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.DBPassword != "new-pwd" { + t.Errorf("password not overwritten, got %q", got.DBPassword) + } +} + +func TestInfisicalBackend_UpdateMissingReturnsTypedError(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + _, err := b.Update(ctx, "no-such", sampleSecret(), "") + var notFound *SecretNotFoundError + if !errors.As(err, ¬Found) { + t.Errorf("expected *SecretNotFoundError, got %T: %v", err, err) + } +} + +func TestInfisicalBackend_GetMissingReturnsTypedError(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + _, err := b.Get(ctx, "no-such") + var notFound *SecretNotFoundError + if !errors.As(err, ¬Found) { + t.Errorf("expected *SecretNotFoundError, got %T: %v", err, err) + } +} + +func TestInfisicalBackend_DeleteIsIdempotent(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create failed: %v", err) + } + if err := b.Delete(ctx, "creds", false); err != nil { + t.Fatalf("first Delete failed: %v", err) + } + if err := b.Delete(ctx, "creds", false); err != nil { + t.Errorf("second Delete returned error: %v", err) + } +} + +func TestInfisicalBackend_ExistsTrueAfterCreate(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + exists, err := b.Exists(ctx, "creds") + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if exists { + t.Error("Exists = true for missing secret") + } + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create failed: %v", err) + } + + exists, err = b.Exists(ctx, "creds") + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if !exists { + t.Error("Exists = false after Create") + } +} + +func TestInfisicalBackend_PayloadIsJSON(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + b := newTestBackend(fc) + + if _, _, err := b.Create(ctx, "creds", "", sampleSecret(), nil, ""); err != nil { + t.Fatalf("Create failed: %v", err) + } + + raw, ok := fc.store["proj-uuid|dev|/|creds"] + if !ok { + t.Fatalf("expected entry, got %v", fc.store) + } + var blob map[string]any + if err := json.Unmarshal([]byte(raw), &blob); err != nil { + t.Fatalf("payload not JSON: %v", err) + } + for _, key := range []string{"DB_HOST", "DB_PORT", "DB_NAME", "DB_USERNAME", "DB_PASSWORD", "POSTGRES_URL"} { + if _, ok := blob[key]; !ok { + t.Errorf("payload missing %q", key) + } + } +} + +func TestInfisicalBackend_LoginErrorPropagates(t *testing.T) { + ctx := context.Background() + fc := newFakeInfisicalClient() + wantErr := errors.New("auth failed") + b := newInfisicalBackendWithClient( + fc, "p", "dev", "/", + func(InfisicalAuth) error { return wantErr }, + InfisicalAuth{ClientID: "x", ClientSecret: "y"}, + ) + + _, err := b.Exists(ctx, "anything") + if err == nil || !strings.Contains(err.Error(), "auth failed") { + t.Errorf("expected wrapped login error, got %v", err) + } +} + +func TestInfisicalBackend_SyncTagsIsNoOp(t *testing.T) { + ctx := context.Background() + b := newTestBackend(newFakeInfisicalClient()) + if err := b.SyncTags(ctx, "any", map[string]string{"a": "1"}); err != nil { + t.Errorf("SyncTags returned error: %v", err) + } +} From e61904354a2cba9a0b578409e8a48daccbce2f2c Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 11:33:00 +0200 Subject: [PATCH 08/13] fix(controller): address PR #135 smoke-test blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes surfaced by smoke testing the secretBackend.kubernetes path against Scaleway managed PostgreSQL: 1. config/manager/manager.yaml: drop --leader-elect arg The flag was no longer registered (LeaderElection is hardcoded true for safe multi-replica operation), so the binary aborted on the unknown flag at startup. Removing the arg matches the new behaviour. BREAKING for kustomize users overriding the arg list. 2. internal/controller/database_controller.go: backend-aware default for secretName. Kubernetes Secret names reject '/'; the rds// path-shape was AWS-SM-friendly only. Use rds-- for the kubernetes backend, keep slashes for AWS / Infisical. 3. RBAC marker: secrets verbs now include create/update/patch/delete. The kubernetes backend needs to write/delete Secrets, not just read. Regenerated config/rbac/role.yaml via 'make manifests'. 4. database_controller.go recovery: when userExists && !secretExists (and not a region-change), rotate the password via CreateUser (idempotent ALTER USER WITH PASSWORD) and recreate the secret instead of aborting with 'cannot recover password'. Previously left CRs perma-stuck because the kubernetes backend's finalizer also failed on the '/'-in-name issue from #2. 5. internal/database/postgres.go: resolve CURRENT_USER to a literal role name before issuing GRANT. Some managed PG providers (Scaleway managed RDB confirmed) reject 'GRANT role TO CURRENT_USER' with XX000 'cannot use special role specifier'. The literal-name form succeeds on RDS and on Scaleway (idempotent — Scaleway's _rdb_superadmin trigger already grants membership at CREATE USER). --- config/manager/manager.yaml | 2 - config/rbac/role.yaml | 4 ++ docs/TROUBLESHOOTING.md | 3 +- internal/controller/database_controller.go | 51 ++++++++++++++++++---- internal/database/postgres.go | 20 ++++++--- 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index fdd51e4..2a8e424 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -29,8 +29,6 @@ spec: containers: - command: - /manager - args: - - --leader-elect image: controller:latest imagePullPolicy: IfNotPresent name: manager diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index fb165eb..4ab2b09 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -16,8 +16,12 @@ rules: resources: - secrets verbs: + - create + - delete - get - list + - patch + - update - watch - apiGroups: - database.opzkit.io diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e87b532..25d114b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -121,8 +121,7 @@ Redeploy with increased verbosity: helm upgrade database-user-operator ./helm/database-user-operator \ --set controllerManager.args[0]="--health-probe-bind-address=:8081" \ --set controllerManager.args[1]="--metrics-bind-address=127.0.0.1:8080" \ - --set controllerManager.args[2]="--leader-elect" \ - --set controllerManager.args[3]="--zap-log-level=debug" + --set controllerManager.args[2]="--zap-log-level=debug" ``` Check logs for more details: diff --git a/internal/controller/database_controller.go b/internal/controller/database_controller.go index 13d7822..57dfa00 100644 --- a/internal/controller/database_controller.go +++ b/internal/controller/database_controller.go @@ -52,7 +52,7 @@ type DatabaseReconciler struct { // +kubebuilder:rbac:groups=database.opzkit.io,resources=databases,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=database.opzkit.io,resources=databases/status,verbs=get;update;patch // +kubebuilder:rbac:groups=database.opzkit.io,resources=databases/finalizers,verbs=update -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -369,9 +369,38 @@ func (r *DatabaseReconciler) reconcileDatabase(ctx context.Context, db *database oldRegion, region) } } else { - // Not a region change - this is an unrecoverable error - return fmt.Errorf("database and/or user exist but secret is missing - cannot recover password (database exists: %v, user exists: %v, secret exists: %v). Please delete the Database CR and recreate it, or manually create the secret with the correct password", - dbExists, userExists, secretExists) + // Not a region change. The user (and optionally database) exists from a + // prior partial reconcile but the destination secret is missing — rotate + // the password and recreate the secret. Safe because the operator owns + // the user (CREATE USER stamps it via COMMENT ON ROLE) and CreateUser is + // idempotent (ALTER USER WITH PASSWORD when the user already exists). + logger.Info("Secret missing for existing user/database — rotating password and recreating secret", + "database", db.Spec.DatabaseName, + "username", username, + "secretName", secretName, + "userExists", userExists, + "dbExists", dbExists) + + password, err = database.GeneratePassword(32) + if err != nil { + return err + } + if err := dbClient.CreateUser(ctx, username, password); err != nil { + return fmt.Errorf("failed to rotate password for existing user: %w", err) + } + db.Status.UserCreated = true + db.Status.ActualUsername = username + + if !dbExists { + logger.Info("Creating missing database during recovery", + "database", db.Spec.DatabaseName, + "owner", username) + if err := dbClient.CreateDatabase(ctx, db.Spec.DatabaseName, username); err != nil { + return err + } + } + db.Status.DatabaseCreated = true + db.Status.ActualSecretName = secretName } } else { @@ -465,7 +494,8 @@ func (r *DatabaseReconciler) storeCredentialsInAWS(ctx context.Context, db *data urlScheme = "mysql" // MariaDB uses mysql:// scheme } - databaseURL := fmt.Sprintf("%s://%s:%s@%s:%d/%s", + databaseURL := fmt.Sprintf( + "%s://%s:%s@%s:%d/%s", urlScheme, url.QueryEscape(username), url.QueryEscape(password), @@ -989,13 +1019,18 @@ func needsReconciliation(db *databasev1alpha1.Database) bool { return false } -// getSecretNameOrDefault returns the secret name from the spec, or generates a default path -// Default format: rds// +// getSecretNameOrDefault returns the secret name from the spec, or generates a backend-aware default. +// AWS / Infisical: rds// (path-shaped names allowed). +// Kubernetes: rds-- (DNS-1123 — '/' is rejected by Secret name validation). func getSecretNameOrDefault(db *databasev1alpha1.Database) string { if db.Spec.SecretName != "" { return db.Spec.SecretName } - return fmt.Sprintf("rds/%s/%s", db.Spec.Engine, db.Spec.DatabaseName) + sep := "/" + if db.Spec.SecretBackend.Kubernetes != nil { + sep = "-" + } + return fmt.Sprintf("rds%s%s%s%s", sep, db.Spec.Engine, sep, db.Spec.DatabaseName) } // getRegion determines the AWS region from the Database spec. diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 97471ff..86a9668 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -200,12 +200,17 @@ func (c *PostgresClient) CreateUser(ctx context.Context, username, password stri return fmt.Errorf("failed to create user: %w", err) } - // Grant the new user to the current admin user so we can SET ROLE to it - // This is required for CREATE DATABASE ... OWNER to work in managed PostgreSQL (RDS, etc) - grantQuery := fmt.Sprintf("GRANT %s TO CURRENT_USER", quoteIdentifier(username)) - _, err = c.db.ExecContext(ctx, grantQuery) - if err != nil { - return fmt.Errorf("failed to grant user to admin: %w", err) + // Grant the new user to the current admin role so we can SET ROLE to it. + // CURRENT_USER is rejected as a role-recipient by some managed PG providers + // (Scaleway managed RDB raises XX000 "cannot use special role specifier"), + // so resolve it to a literal role name first. + var adminRole string + if err := c.db.QueryRowContext(ctx, "SELECT current_user").Scan(&adminRole); err != nil { + return fmt.Errorf("failed to resolve current admin role: %w", err) + } + grantQuery := fmt.Sprintf("GRANT %s TO %s", quoteIdentifier(username), quoteIdentifier(adminRole)) + if _, err := c.db.ExecContext(ctx, grantQuery); err != nil { + return fmt.Errorf("failed to grant user to admin (%s): %w", adminRole, err) } } @@ -246,7 +251,8 @@ func (c *PostgresClient) GrantPrivileges(ctx context.Context, username, dbName s } // Create connection string for the target database - targetConnStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", + targetConnStr := fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=%s", url.QueryEscape(connInfo.Username), url.QueryEscape(connInfo.Password), connInfo.Host, From 53722d4aaaf01122192de4086d6782406f3c0c49 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 11:34:43 +0200 Subject: [PATCH 09/13] docs: update CRD examples and prose for new spec.connectionString/secretBackend Mass rename across README and docs: spec.connectionStringSecretRef -> spec.connectionString.kubernetes spec.connectionStringAWSSecretRef -> spec.connectionString.aws spec.awsSecretsManager -> spec.secretBackend.aws Also refreshed TROUBLESHOOTING.md prose to reflect that AWS permissions are now optional (depend on the chosen backend, not always required) and removed the stale --leader-elect example since the flag is no longer registered. --- README.md | 96 ++++++++++++++++------------- docs/AWS_CREDENTIALS.md | 12 ++-- docs/DEVELOPMENT.md | 10 ++-- docs/INSTALLATION.md | 10 ++-- docs/SECRET_TEMPLATES.md | 78 ++++++++++++++---------- docs/TROUBLESHOOTING.md | 29 ++++----- docs/USAGE.md | 126 ++++++++++++++++++++++----------------- 7 files changed, 206 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index 8f4c523..87bbeda 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,14 @@ metadata: spec: engine: postgres databaseName: myapp_db - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 - tags: - Environment: production + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 + tags: + Environment: production ``` 3. Apply and check status: @@ -136,12 +138,14 @@ metadata: spec: engine: mysql databaseName: myapp_db - connectionStringSecretRef: - name: mysql-admin - awsSecretsManager: - region: us-east-1 - tags: - Environment: production + connectionString: + kubernetes: + name: mysql-admin + secretBackend: + aws: + region: us-east-1 + tags: + Environment: production ``` 3. Apply and check status: @@ -181,10 +185,12 @@ metadata: spec: engine: mariadb # Uses MySQL driver and protocol databaseName: myapp_db - connectionStringSecretRef: - name: mariadb-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: mariadb-admin + secretBackend: + aws: + region: us-east-1 ``` The operator treats MariaDB identically to MySQL, using the same driver and SQL syntax. The stored secret will use the `MYSQL_URL` format for compatibility. @@ -205,10 +211,12 @@ metadata: spec: engine: postgres databaseName: spring_app - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 secretTemplate: | { "spring.datasource.url": "jdbc:postgresql://{{.DBHost}}:{{.DBPort}}/{{.DBName}}", @@ -228,10 +236,12 @@ metadata: spec: engine: mysql databaseName: simple_app - connectionStringSecretRef: - name: mysql-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: mysql-admin + secretBackend: + aws: + region: us-east-1 secretTemplate: | { "connectionString": "{{.DatabaseURL}}" @@ -253,10 +263,12 @@ spec: username: readonly_user privileges: - SELECT - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 ``` ### Custom Username and Secret Path @@ -271,14 +283,16 @@ spec: databaseName: myapp_db username: custom_user secretName: /custom/path/myapp-credentials - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 - description: "Custom application database" - tags: - Team: platform - CostCenter: engineering + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 + description: "Custom application database" + tags: + Team: platform + CostCenter: engineering ``` ### Delete Resources on CR Deletion @@ -294,10 +308,12 @@ spec: engine: postgres databaseName: temp_db retainOnDelete: false # Delete database and user when CR is deleted - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 ``` For more advanced examples and secret template documentation, see [Secret Templates Guide](docs/SECRET_TEMPLATES.md). diff --git a/docs/AWS_CREDENTIALS.md b/docs/AWS_CREDENTIALS.md index 7560db5..7da0f41 100644 --- a/docs/AWS_CREDENTIALS.md +++ b/docs/AWS_CREDENTIALS.md @@ -178,8 +178,9 @@ The connection string source only determines where to read the ADMIN connection - **Read admin connection from Kubernetes Secret:** ```yaml spec: - connectionStringSecretRef: - name: postgres-admin-connection + connectionString: + kubernetes: + name: postgres-admin-connection ``` - Admin credentials: Kubernetes Secret - Created credentials: AWS Secrets Manager ⚠️ @@ -187,9 +188,10 @@ The connection string source only determines where to read the ADMIN connection - **Read admin connection from AWS Secrets Manager:** ```yaml spec: - connectionStringAWSSecretRef: - secretName: rds/admin/postgres-connection - region: us-east-1 + connectionString: + aws: + secretName: rds/admin/postgres-connection + region: us-east-1 ``` - Admin credentials: AWS Secrets Manager - Created credentials: AWS Secrets Manager diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index aa061ba..e7163b4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -198,11 +198,13 @@ metadata: spec: engine: postgres databaseName: test_database - connectionStringSecretRef: - name: postgres-admin + connectionString: + kubernetes: + name: postgres-admin retainOnDelete: false # Cleanup after test - awsSecretsManager: - region: us-east-1 + secretBackend: + aws: + region: us-east-1 ``` 3. Apply and observe: diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 2c08647..b29f0e7 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -159,10 +159,12 @@ metadata: spec: engine: postgres databaseName: test_database - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 retainOnDelete: false # Cleanup after testing ``` diff --git a/docs/SECRET_TEMPLATES.md b/docs/SECRET_TEMPLATES.md index 0befa84..a812540 100644 --- a/docs/SECRET_TEMPLATES.md +++ b/docs/SECRET_TEMPLATES.md @@ -57,10 +57,12 @@ metadata: spec: engine: postgres databaseName: myapp_db - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 # No secretTemplate specified - uses default format ``` @@ -88,10 +90,12 @@ metadata: spec: engine: postgres databaseName: spring_app - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 secretTemplate: | { "spring.datasource.url": "jdbc:postgresql://{{.DBHost}}:{{.DBPort}}/{{.DBName}}", @@ -123,10 +127,12 @@ metadata: spec: engine: mysql databaseName: simple_app - connectionStringSecretRef: - name: mysql-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: mysql-admin + secretBackend: + aws: + region: us-east-1 secretTemplate: | { "connectionString": "{{.DatabaseURL}}" @@ -152,10 +158,12 @@ metadata: spec: engine: postgres databaseName: env_app - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 secretTemplate: | { "DATABASE_HOST": "{{.DBHost}}", @@ -191,10 +199,12 @@ metadata: spec: engine: postgres databaseName: django_app - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 secretTemplate: | { "ENGINE": "django.db.backends.postgresql", @@ -230,10 +240,12 @@ metadata: spec: engine: mysql databaseName: nodejs_app - connectionStringSecretRef: - name: mysql-admin - awsSecretsManager: - region: us-east-1 + connectionString: + kubernetes: + name: mysql-admin + secretBackend: + aws: + region: us-east-1 secretTemplate: | { "type": "mysql", @@ -328,14 +340,16 @@ metadata: spec: engine: postgres databaseName: myapp - connectionStringSecretRef: - name: postgres-admin - awsSecretsManager: - region: us-east-1 - description: "Database credentials for Spring Boot application" - tags: - Application: myapp - Framework: spring-boot + connectionString: + kubernetes: + name: postgres-admin + secretBackend: + aws: + region: us-east-1 + description: "Database credentials for Spring Boot application" + tags: + Application: myapp + Framework: spring-boot # Custom template for Spring Boot application.properties format secretTemplate: | { diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 25d114b..4591a6b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -6,7 +6,7 @@ This error occurs when the operator tries to access AWS Secrets Manager but doesn't have valid AWS credentials. -**Important:** The operator ALWAYS needs AWS permissions because created database credentials are ALWAYS stored in AWS Secrets Manager, regardless of where you store the admin connection string. +**Important:** AWS permissions are required only when `spec.connectionString.aws` (admin DSN read from AWS Secrets Manager) or `spec.secretBackend.aws` (generated credentials written to AWS Secrets Manager) is configured. If both the admin DSN and the generated-credential destination use Kubernetes or Infisical, AWS permissions are not needed. #### Step 1: Verify the pod has AWS credentials @@ -187,27 +187,28 @@ kubectl logs -l control-plane=controller-manager -c manager --tail=100 -f ## Credential Storage Backend -**Created database credentials are ALWAYS stored in AWS Secrets Manager.** +The admin connection string source (`spec.connectionString`) and the generated-credential destination (`spec.secretBackend`) are independent and configured separately. -The connection string source only determines where to read the ADMIN connection string: +| `spec.connectionString.*` | Admin DSN read from | +|---------------------------|---------------------| +| `kubernetes` | Kubernetes Secret in the cluster | +| `aws` | AWS Secrets Manager | -| Connection String Source | Admin Connection From | Created Credentials Stored In | -|-------------------------|----------------------|------------------------------| -| `connectionStringSecretRef` | Kubernetes Secret | AWS Secrets Manager ⚠️ | -| `connectionStringAWSSecretRef` | AWS Secrets Manager | AWS Secrets Manager | +| `spec.secretBackend.*` | Generated credentials written to | +|------------------------|----------------------------------| +| `aws` | AWS Secrets Manager | +| `kubernetes` | Kubernetes Secret in the cluster | +| `infisical` | Infisical Cloud (Universal Auth) | -**This means:** AWS permissions are ALWAYS required, even if you use Kubernetes secrets for the admin connection. +AWS permissions are required only when either side uses `aws`. -### Verify which backend is being used +### Verify which backends are being used ```bash -kubectl get database myapp-database -o jsonpath='{.spec}' | jq +kubectl get database myapp-database -o jsonpath='{.spec.connectionString}' | jq +kubectl get database myapp-database -o jsonpath='{.spec.secretBackend}' | jq ``` -Look for either: -- `connectionStringSecretRef`: Using Kubernetes -- `connectionStringAWSSecretRef`: Using AWS - ## Rate Limiting / Exponential Backoff The operator uses exponential backoff for errors: diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ed07c7..7eaa74e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -24,8 +24,9 @@ metadata: spec: engine: postgres databaseName: myapp_db - connectionStringSecretRef: - name: postgres-admin + connectionString: + kubernetes: + name: postgres-admin ``` This creates: @@ -45,7 +46,7 @@ This creates: |-------|------|-------------| | `engine` | string | Database engine: `postgres`, `postgresql`, `mysql`, `mariadb` | | `databaseName` | string | Name of database to create (pattern: `^[a-z][a-z0-9_]*$`, max 63 chars) | -| `connectionStringSecretRef` OR `connectionStringAWSSecretRef` | object | Admin connection string reference | +| `connectionString.kubernetes` OR `connectionString.aws` | object | Admin connection string reference | #### Optional Fields @@ -55,16 +56,17 @@ This creates: | `secretName` | string | `rds//` | AWS secret path | | `privileges` | []string | `["ALL"]` | Privileges to grant | | `retainOnDelete` | bool | `true` | Retain resources on CR deletion | -| `awsSecretsManager` | object | - | AWS Secrets Manager config | +| `secretBackend.aws` | object | - | AWS Secrets Manager config | -### connectionStringSecretRef +### connectionString.kubernetes Reference to Kubernetes Secret containing admin connection string: ```yaml -connectionStringSecretRef: - name: postgres-admin # required - key: connectionString # optional, defaults to "connectionString" +connectionString: + kubernetes: + name: postgres-admin # required + key: connectionString # optional, defaults to "connectionString" ``` The referenced secret should contain: @@ -77,29 +79,31 @@ stringData: connectionString: "postgresql://admin:password@db.example.com:5432/postgres?sslmode=require" ``` -### connectionStringAWSSecretRef +### connectionString.aws Reference to AWS Secrets Manager secret containing admin connection string: ```yaml -connectionStringAWSSecretRef: - secretName: rds/admin/postgres # required - name or ARN - key: connectionString # optional, defaults to "connectionString" - region: us-east-1 # optional, uses AWS SDK default if not specified +connectionString: + aws: + secretName: rds/admin/postgres # required - name or ARN + key: connectionString # optional, defaults to "connectionString" + region: us-east-1 # optional, uses AWS SDK default if not specified ``` -### awsSecretsManager +### secretBackend.aws Configuration for storing created credentials: ```yaml -awsSecretsManager: - region: us-east-1 # optional, defaults to AWS SDK default - description: "DB credentials" # optional - tags: # optional - Environment: production - Application: myapp - ManagedBy: database-user-operator +secretBackend: + aws: + region: us-east-1 # optional, defaults to AWS SDK default + description: "DB credentials" # optional + tags: # optional + Environment: production + Application: myapp + ManagedBy: database-user-operator ``` **Note**: Created credentials are **always** stored in AWS Secrets Manager, regardless of where the admin connection string comes from. @@ -107,8 +111,8 @@ awsSecretsManager: ### Region Priority The operator determines AWS region in this order: -1. `spec.awsSecretsManager.region` (highest priority) -2. `spec.connectionStringAWSSecretRef.region` +1. `spec.secretBackend.aws.region` (highest priority) +2. `spec.connectionString.aws.region` 3. AWS SDK default (environment variables, instance metadata, etc.) ### Privileges @@ -147,12 +151,14 @@ metadata: spec: engine: postgres databaseName: app_database - connectionStringSecretRef: - name: postgres-admin-connection - awsSecretsManager: - region: us-east-1 - tags: - Environment: production + connectionString: + kubernetes: + name: postgres-admin-connection + secretBackend: + aws: + region: us-east-1 + tags: + Environment: production ``` ### Example 2: Custom Username and Secret Path @@ -167,8 +173,9 @@ spec: databaseName: analytics username: analytics_user secretName: /myapp/databases/analytics - connectionStringSecretRef: - name: postgres-admin + connectionString: + kubernetes: + name: postgres-admin privileges: - SELECT - INSERT @@ -184,15 +191,17 @@ metadata: spec: engine: postgresql databaseName: api_db - connectionStringAWSSecretRef: - secretName: rds/admin/postgres-main - region: eu-west-1 - awsSecretsManager: - region: eu-west-1 - description: "API database credentials" - tags: - Team: backend - Service: api + connectionString: + aws: + secretName: rds/admin/postgres-main + region: eu-west-1 + secretBackend: + aws: + region: eu-west-1 + description: "API database credentials" + tags: + Team: backend + Service: api ``` ### Example 4: Temporary Database (Cleanup on Deletion) @@ -207,8 +216,9 @@ spec: engine: postgres databaseName: test_temp retainOnDelete: false # Delete all resources on CR deletion - connectionStringSecretRef: - name: postgres-admin + connectionString: + kubernetes: + name: postgres-admin ``` ### Example 5: Read-Only User @@ -224,8 +234,9 @@ spec: username: readonly_user privileges: - SELECT - connectionStringSecretRef: - name: postgres-admin + connectionString: + kubernetes: + name: postgres-admin ``` ### Example 6: MySQL Database @@ -239,12 +250,14 @@ spec: engine: mysql databaseName: myapp_db username: myapp_user - connectionStringSecretRef: - name: mysql-admin - awsSecretsManager: - region: us-east-1 - tags: - Environment: production + connectionString: + kubernetes: + name: mysql-admin + secretBackend: + aws: + region: us-east-1 + tags: + Environment: production ``` ### Example 7: MariaDB with Custom Secret Path @@ -258,10 +271,11 @@ spec: engine: mariadb databaseName: analytics_db secretName: /mariadb/production/analytics - connectionStringAWSSecretRef: - secretName: rds/admin/mariadb-main - key: connectionString - region: eu-west-1 + connectionString: + aws: + secretName: rds/admin/mariadb-main + key: connectionString + region: eu-west-1 ``` ## Secret Format @@ -427,8 +441,8 @@ Use this for temporary/test databases. - Operator restart (idempotent checks prevent duplicates) #### What operations are safe? -- ✅ Updating `awsSecretsManager.tags` - Only updates secret tags -- ✅ Updating `awsSecretsManager.description` - Only updates description +- ✅ Updating `secretBackend.aws.tags` - Only updates secret tags +- ✅ Updating `secretBackend.aws.description` - Only updates description - ✅ Updating `privileges` - Reapplies grants - ❌ Changing `databaseName` - Not supported (create new resource) - ❌ Changing `username` - Not supported (create new resource) From d3e10f099c938d7d6213d6a10e552cde6993e6e8 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 12:13:21 +0200 Subject: [PATCH 10/13] fix(database): grant admin role WITH INHERIT, SET for PG 16+ PG 16 changed GRANT defaults to NOINHERIT, NOSET, NOADMIN. CREATE/ALTER DATABASE ... OWNER now requires the executor to hold SET privilege on the owner role, not just bare membership. Aurora/RDS paper over this via an event trigger on CREATE ROLE that grants WITH INHERIT, SET, ADMIN OPTION. Scaleway managed RDB and self-hosted PG 16+ do not, so the operator failed with 'must be able to SET ROLE' (42501) on those providers. Add WITH INHERIT TRUE, SET TRUE to the admin-grant statement. PG <16 honours INHERIT and ignores the SET column cleanly. --- internal/database/postgres.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 86a9668..a529ef5 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -200,15 +200,21 @@ func (c *PostgresClient) CreateUser(ctx context.Context, username, password stri return fmt.Errorf("failed to create user: %w", err) } - // Grant the new user to the current admin role so we can SET ROLE to it. - // CURRENT_USER is rejected as a role-recipient by some managed PG providers - // (Scaleway managed RDB raises XX000 "cannot use special role specifier"), - // so resolve it to a literal role name first. + // Grant new user to admin with INHERIT + SET so CREATE/ALTER DATABASE + // ... OWNER works on PG 16+ (defaults changed to NOINHERIT, NOSET). + // Resolve CURRENT_USER to a literal name first (some managed PG + // providers reject CURRENT_USER as a special role specifier — XX000). + // Aurora/RDS auto-grant these flags via an event trigger; Scaleway + // managed RDB and self-hosted PG 16+ do not. var adminRole string if err := c.db.QueryRowContext(ctx, "SELECT current_user").Scan(&adminRole); err != nil { return fmt.Errorf("failed to resolve current admin role: %w", err) } - grantQuery := fmt.Sprintf("GRANT %s TO %s", quoteIdentifier(username), quoteIdentifier(adminRole)) + grantQuery := fmt.Sprintf( + "GRANT %s TO %s WITH INHERIT TRUE, SET TRUE", + quoteIdentifier(username), + quoteIdentifier(adminRole), + ) if _, err := c.db.ExecContext(ctx, grantQuery); err != nil { return fmt.Errorf("failed to grant user to admin (%s): %w", adminRole, err) } From 2c5106b125acd34f0d4bb586d6b2bb3ec712ac41 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 12:17:05 +0200 Subject: [PATCH 11/13] docs: document supported database versions PostgreSQL 14+ (tested on 15/16/17), MySQL 8.0+, MariaDB 10.6+. Also clarify AWS credentials are only required when the AWS backends (secretBackend.aws or connectionString.aws) are used. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 87bbeda..6589361 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ The Database User Operator creates and manages databases and users declaratively ### Prerequisites - Kubernetes cluster (v1.28+) -- PostgreSQL, MySQL, or MariaDB database instance -- AWS credentials with Secrets Manager permissions +- PostgreSQL 14+ (tested on 15, 16, 17), MySQL 8.0+, or MariaDB 10.6+ +- AWS credentials with Secrets Manager permissions (only when using `secretBackend.aws` or `connectionString.aws`) ### Installation From ee850777f5b1d7f835ef41e0b303ffc2a38a615f Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 12:46:35 +0200 Subject: [PATCH 12/13] test(integration): update CRs to new spec.connectionString/secretBackend shape Old ConnectionStringSecretRef + bare SecretName fields removed in 158fda5. Integration tests still referenced them and failed to build. Replace with spec.connectionString.kubernetes (admin DSN source) and spec.secretBackend.aws (generated-credential dest) across all 12 createDatabase call sites. --- test/integration/operator_test.go | 156 +++++++++++++++++++++++------- 1 file changed, 120 insertions(+), 36 deletions(-) diff --git a/test/integration/operator_test.go b/test/integration/operator_test.go index a168cc1..7cb020e 100644 --- a/test/integration/operator_test.go +++ b/test/integration/operator_test.go @@ -105,9 +105,16 @@ var _ = Describe("Database Operator Integration Tests", func() { Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: "testdb", Username: "testuser", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "postgres-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "postgres-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: secretName, }) @@ -203,9 +210,16 @@ var _ = Describe("Database Operator Integration Tests", func() { createDatabase(namespace, dbName, databasev1alpha1.DatabaseSpec{ Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: "defaultdb", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "postgres-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "postgres-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, }) @@ -232,9 +246,16 @@ var _ = Describe("Database Operator Integration Tests", func() { createDatabase(namespace, dbName, databasev1alpha1.DatabaseSpec{ Engine: databasev1alpha1.DatabaseEnginePostgreSQL, DatabaseName: "postgresqldb", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "postgres-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "postgres-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, }) @@ -266,9 +287,16 @@ var _ = Describe("Database Operator Integration Tests", func() { DatabaseName: "retaindb", Username: "retainuser", RetainOnDelete: &retainTrue, - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "postgres-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "postgres-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: secretName, }) @@ -339,9 +367,16 @@ var _ = Describe("Database Operator Integration Tests", func() { Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: "orphaneddb", Username: "orphaneduser", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "postgres-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "postgres-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: secretName, }) @@ -372,9 +407,16 @@ var _ = Describe("Database Operator Integration Tests", func() { Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: "orphaneddb", Username: "orphaneduser", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "postgres-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "postgres-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: secretName, }) @@ -416,9 +458,16 @@ var _ = Describe("Database Operator Integration Tests", func() { Engine: databasev1alpha1.DatabaseEngineMySQL, DatabaseName: "mysqldb", Username: "mysqluser", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "mysql-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "mysql-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: secretName, }) @@ -515,9 +564,16 @@ var _ = Describe("Database Operator Integration Tests", func() { createDatabase(namespace, dbName, databasev1alpha1.DatabaseSpec{ Engine: databasev1alpha1.DatabaseEngineMySQL, DatabaseName: "defaultdb", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "mysql-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "mysql-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, }) @@ -544,9 +600,16 @@ var _ = Describe("Database Operator Integration Tests", func() { createDatabase(namespace, dbName, databasev1alpha1.DatabaseSpec{ Engine: databasev1alpha1.DatabaseEngineMariaDB, DatabaseName: "mariadb", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "mysql-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "mysql-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, }) @@ -581,9 +644,16 @@ var _ = Describe("Database Operator Integration Tests", func() { Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: "multidb_pg", Username: "pguser", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "postgres-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "postgres-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: pgSecretName, }) @@ -592,9 +662,16 @@ var _ = Describe("Database Operator Integration Tests", func() { Engine: databasev1alpha1.DatabaseEngineMySQL, DatabaseName: "multidb_mysql", Username: "mysqluser", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "mysql-connection", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "mysql-connection", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: mysqlSecretName, }) @@ -662,9 +739,16 @@ var _ = Describe("Database Operator Integration Tests", func() { createDatabase(namespace, dbName, databasev1alpha1.DatabaseSpec{ Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: "errordb", - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: "non-existent-secret", - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: "non-existent-secret", + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, }) From 13081f98a9c82127923c0315484824cc470cc441 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 13:08:19 +0200 Subject: [PATCH 13/13] test(integration): assert recovery (not error) when secret missing Old test expected phase=Error with message about cannot recover password. New behaviour (introduced with the smoke-test blocker fix in e619043): controller rotates the password via ALTER USER and recreates the secret. Updated test now waits for Ready phase and verifies the secret reappears in AWS Secrets Manager with the same username and a non-empty password. --- test/integration/operator_test.go | 42 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/test/integration/operator_test.go b/test/integration/operator_test.go index 7cb020e..1990d58 100644 --- a/test/integration/operator_test.go +++ b/test/integration/operator_test.go @@ -44,7 +44,8 @@ var _ = Describe("Database Operator Integration Tests", func() { }, nil }) - cfg, err := config.LoadDefaultConfig(awsCtx, + cfg, err := config.LoadDefaultConfig( + awsCtx, config.WithRegion("us-east-1"), config.WithEndpointResolverWithOptions(customResolver), config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) { @@ -357,7 +358,11 @@ var _ = Describe("Database Operator Integration Tests", func() { }, "30s", "1s").Should(Succeed()) }) - It("Should detect and report error when database/user exist but secret is missing", func() { + It("Should recover by rotating password when database/user exist but secret is missing", func() { + // Recovery path introduced in PR #135: when the user (and optionally + // database) already exist from a prior partial reconcile but the + // destination secret is gone, the controller rotates the password via + // ALTER USER and recreates the secret rather than erroring out. dbName1 := "test-pg-orphaned-" + randomString(5) dbName2 := "test-pg-orphaned-" + randomString(5) secretName := fmt.Sprintf("test/databases/%s/credentials", dbName1) @@ -402,7 +407,6 @@ var _ = Describe("Database Operator Integration Tests", func() { Expect(err).NotTo(HaveOccurred()) By("Creating second Database CR with same database/username but no secret") - // This simulates the error scenario: database and user exist, but secret is missing createDatabase(namespace, dbName2, databasev1alpha1.DatabaseSpec{ Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: "orphaneddb", @@ -421,25 +425,29 @@ var _ = Describe("Database Operator Integration Tests", func() { SecretName: secretName, }) - By("Verifying the operator detects the error condition") - Eventually(func() string { - db, err := getDatabase(namespace, dbName2) - if err != nil { - return "" - } - return db.Status.Phase - }, "30s", "1s").Should(Equal("Error")) + By("Waiting for the operator to recover (rotate password and recreate secret)") + waitForDatabaseCreated(namespace, dbName2) - By("Verifying the error message is correct") + By("Verifying status reflects successful recovery") db, err := getDatabase(namespace, dbName2) Expect(err).NotTo(HaveOccurred()) - Expect(db.Status.Message).To(ContainSubstring("database and/or user exist but secret is missing")) - Expect(db.Status.Message).To(ContainSubstring("cannot recover password")) + Expect(db.Status.DatabaseCreated).To(BeTrue()) + Expect(db.Status.UserCreated).To(BeTrue()) + Expect(db.Status.SecretCreated).To(BeTrue()) + Expect(db.Status.Phase).To(Or(Equal("Ready"), Equal("Reconciling"))) - By("Cleaning up - deleting the second Database CR and PostgreSQL resources") - // Set retainOnDelete to false to ensure cleanup - db, err = getDatabase(namespace, dbName2) + By("Verifying secret was recreated in AWS Secrets Manager") + result, err := smClient.GetSecretValue(awsCtx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretName), + }) Expect(err).NotTo(HaveOccurred()) + Expect(result.SecretString).NotTo(BeNil()) + var secretData map[string]interface{} + Expect(json.Unmarshal([]byte(*result.SecretString), &secretData)).To(Succeed()) + Expect(secretData["DB_USERNAME"]).To(Equal("orphaneduser")) + Expect(secretData["DB_PASSWORD"]).NotTo(BeEmpty()) + + By("Cleaning up - deleting the second Database CR and PostgreSQL resources") retainFalse := false db.Spec.RetainOnDelete = &retainFalse Expect(k8sClient.Update(ctx, db)).Should(Succeed())