diff --git a/README.md b/README.md index 8f4c523..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 @@ -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/api/v1alpha1/database_types.go b/api/v1alpha1/database_types.go index 9f05fab..4354b8e 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,69 @@ 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 +// (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"` + + // ProjectID is the Infisical project UUID. // +kubebuilder:validation:Required - Name string `json:"name"` + ProjectID string `json:"projectID"` + + // Environment is the Infisical environment slug (e.g. dev, staging, prod). + // +kubebuilder:validation:Required + Environment string `json:"environment"` - // Key within the secret - // Defaults to "connectionString" + // SecretsPath is the folder 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` and `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 +187,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 @@ -154,8 +230,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 +248,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 +277,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/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/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/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/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/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 e87b532..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 @@ -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: @@ -188,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) 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 ac1aadd..5a195e3 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 @@ -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 @@ -63,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: - 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: + kubernetes: 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 @@ -220,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` and `clientSecret` keys for + Infisical Universal Auth. + properties: + name: + description: Name of the Secret. + type: string + required: + - name + type: object + 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 + projectID: + description: ProjectID is the Infisical project UUID. + type: string + secretsPath: + default: / + description: 'SecretsPath is the folder path inside the environment. + Default: "/"' + type: string + required: + - authSecretRef + - environment + - projectID + 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: |- @@ -240,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 @@ -256,16 +314,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: |- @@ -306,12 +356,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 @@ -360,10 +405,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 +412,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..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) { @@ -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 } @@ -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.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 } } - - // 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 @@ -362,19 +317,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 +341,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 +355,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,12 +366,41 @@ 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 - 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 { @@ -508,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), @@ -530,105 +517,85 @@ 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.AWSSecretsManager != nil && db.Spec.AWSSecretsManager.Region != "" { - regionSource = "spec.awsSecretsManager.region" - } else if db.Spec.ConnectionStringAWSSecretRef != nil && db.Spec.ConnectionStringAWSSecretRef.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 - regionChanged := db.Status.SecretRegion != "" && db.Status.SecretRegion != region + oldRegion := secrets.AWSRegionFromARN(db.Status.SecretLocator) + 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, "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 { - 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 && db.Status.SecretRegion != "" { + // 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", 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)) } } } - 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 @@ -637,7 +604,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", @@ -650,22 +617,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) } } } @@ -673,111 +638,78 @@ 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 } } - 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.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 } } - - // 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 && db.Status.SecretRegion != "" && 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", db.Status.SecretRegion, + "oldRegion", oldRegion, "newRegion", region, - "newSecretARN", secretARN) + "newSecretLocator", secretLocator) - 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 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", db.Status.SecretRegion, - "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) - } + logger.Info("Successfully deleted secret from old region", + "secretName", secretName, + "oldRegion", oldRegion) } - } else if regionChanged && db.Status.SecretRegion != "" && secretARN == "" { + } else if regionChanged && secretLocator == "" { 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 = secretLocator db.Status.SecretVersion = versionID db.Status.SecretFormatVersion = "v2" - db.Status.SecretRegion = region db.Status.ConnectionInfo = databasev1alpha1.ConnectionInfo{ Host: connInfo.Host, Port: port, @@ -889,52 +821,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) } } @@ -958,7 +866,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) @@ -977,37 +885,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 { @@ -1061,23 +969,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 @@ -1110,59 +1019,28 @@ 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) -} - -// 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 - } + sep := "/" + if db.Spec.SecretBackend.Kubernetes != nil { + sep = "-" } - return toAdd + 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 -// 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.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..2501e14 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) } }) } @@ -516,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. diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 97471ff..a529ef5 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -200,12 +200,23 @@ 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 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 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) } } @@ -246,7 +257,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, diff --git a/internal/secrets/aws_backend_adapter.go b/internal/secrets/aws_backend_adapter.go new file mode 100644 index 0000000..1329f4e --- /dev/null +++ b/internal/secrets/aws_backend_adapter.go @@ -0,0 +1,98 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +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 +// 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/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/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..08df8a7 --- /dev/null +++ b/internal/secrets/factory.go @@ -0,0 +1,117 @@ +/* +Copyright 2025 OpzKit + +Licensed under the MIT License. +See LICENSE file in the project root for full license information. +*/ + +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" +) + +// ErrNoBackendConfigured is returned when a Database resource doesn't +// specify any of the supported destination backends. +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") + +// 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) 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 + + 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 sb.AWS != nil: + if err := ValidateRegion(sb.AWS.Region); err != nil { + return nil, err + } + return NewAWSSecretsManagerClient(ctx, sb.AWS.Region) + + 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 + + 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) + } +} 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") + } +} diff --git a/test/integration/operator_test.go b/test/integration/operator_test.go index a168cc1..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) { @@ -105,9 +106,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 +211,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 +247,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 +288,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, }) @@ -329,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) @@ -339,9 +372,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, }) @@ -367,37 +407,47 @@ 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", 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, }) - 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()) @@ -416,9 +466,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 +572,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 +608,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 +652,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 +670,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 +747,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", + }, }, })