diff --git a/api/v1alpha1/database_types.go b/api/v1alpha1/database_types.go index 4354b8e..12e01d9 100644 --- a/api/v1alpha1/database_types.go +++ b/api/v1alpha1/database_types.go @@ -97,6 +97,10 @@ type SecretBackend struct { // via Universal Auth. // +optional Infisical *InfisicalSecretBackend `json:"infisical,omitempty"` + + // Scaleway stores credentials in Scaleway Secret Manager. + // +optional + Scaleway *ScalewaySecretBackend `json:"scaleway,omitempty"` } // AWSSecretBackend contains AWS Secrets Manager configuration. @@ -158,6 +162,45 @@ type InfisicalSecretBackend struct { AuthSecretRef KubernetesSecretRef `json:"authSecretRef"` } +// ScalewaySecretBackend stores generated credentials in Scaleway Secret +// Manager. +// +// Scaleway scopes secrets to a (Region, Project) pair. Authentication uses +// a Scaleway IAM API key (access_key + secret_key) read from a Kubernetes +// Secret in the same namespace as the Database resource. The standard +// Scaleway IAM permission set required is `SecretManagerSecretAccess` at +// Project scope plus `SecretManagerReadOnly` at Org scope (the latter +// covers the list-by-name lookup the controller performs on every +// reconcile). +type ScalewaySecretBackend struct { + // Region is the Scaleway region for Secret Manager (e.g. fr-par, nl-ams, pl-waw). + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=fr-par;nl-ams;pl-waw + Region string `json:"region"` + + // ProjectID is the Scaleway Project UUID owning the secret. + // +kubebuilder:validation:Required + ProjectID string `json:"projectID"` + + // Description is the description applied to the Scaleway secret on + // create/update. + // +optional + Description string `json:"description,omitempty"` + + // Tags are key/value tags applied to the Scaleway secret. Scaleway + // stores tags as a flat list of strings; the controller serialises + // each entry as "key=value" on update. + // +optional + Tags map[string]string `json:"tags,omitempty"` + + // AuthSecretRef references a Kubernetes Secret in the same namespace + // as the Database holding `access_key` and `secret_key` data keys + // for the Scaleway IAM API key. Same shape as the Secret consumed by + // the Scaleway provider in External Secrets Operator. + // +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 { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4093faf..beca326 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -263,6 +263,29 @@ func (in *KubernetesSecretRef) DeepCopy() *KubernetesSecretRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScalewaySecretBackend) DeepCopyInto(out *ScalewaySecretBackend) { + *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + out.AuthSecretRef = in.AuthSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScalewaySecretBackend. +func (in *ScalewaySecretBackend) DeepCopy() *ScalewaySecretBackend { + if in == nil { + return nil + } + out := new(ScalewaySecretBackend) + 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 @@ -281,6 +304,11 @@ func (in *SecretBackend) DeepCopyInto(out *SecretBackend) { *out = new(InfisicalSecretBackend) **out = **in } + if in.Scaleway != nil { + in, out := &in.Scaleway, &out.Scaleway + *out = new(ScalewaySecretBackend) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretBackend. diff --git a/config/samples/database_v1alpha1_database_scaleway.yaml b/config/samples/database_v1alpha1_database_scaleway.yaml new file mode 100644 index 0000000..6cb2038 --- /dev/null +++ b/config/samples/database_v1alpha1_database_scaleway.yaml @@ -0,0 +1,33 @@ +apiVersion: database.opzkit.io/v1alpha1 +kind: Database +metadata: + name: myapp-database-scaleway +spec: + engine: postgres + databaseName: myapp_db + + # Source of the admin DSN. Scaleway managed Postgres exposes the admin + # DSN as a Scaleway IAM secret in Secret Manager; the in-cluster path + # is to mirror it into a Kubernetes Secret via External Secrets and + # reference that here. Plain `connectionString.aws` is also valid if + # the admin DSN happens to live in AWS Secrets Manager. + connectionString: + kubernetes: + name: postgres-admin-connection + key: connectionString # optional, defaults to "connectionString" + + retainOnDelete: false + + # Destination for the generated user credentials. + secretBackend: + scaleway: + region: fr-par + projectID: 5a339eb9-920f-4256-bbf2-d9ae6e0fb676 + description: "Database credentials for myapp" + tags: + Environment: production + Application: myapp + authSecretRef: + # Kubernetes Secret holding `access_key` + `secret_key` keys + # (same shape as ESO's Scaleway provider authentication). + name: scaleway-dbuo-creds diff --git a/go.mod b/go.mod index 1d39694..45d9075 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/onsi/ginkgo/v2 v2.28.3 github.com/onsi/gomega v1.40.0 github.com/prometheus/client_golang v1.23.2 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 k8s.io/api v0.36.0 k8s.io/apimachinery v0.36.0 k8s.io/client-go v0.36.0 @@ -117,6 +118,7 @@ require ( 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 + gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/apiextensions-apiserver v0.36.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect diff --git a/go.sum b/go.sum index 0e2e18d..5bf678e 100644 --- a/go.sum +++ b/go.sum @@ -201,6 +201,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 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/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= 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= @@ -359,6 +361,8 @@ 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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= 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 5a195e3..145a571 100644 --- a/helm/database-user-operator/crds/database.opzkit.io_databases.yaml +++ b/helm/database-user-operator/crds/database.opzkit.io_databases.yaml @@ -273,6 +273,52 @@ spec: the Database resource. type: string type: object + scaleway: + description: Scaleway stores credentials in Scaleway Secret Manager. + properties: + authSecretRef: + description: |- + AuthSecretRef references a Kubernetes Secret in the same namespace + as the Database holding `access_key` and `secret_key` data keys + for the Scaleway IAM API key. Same shape as the Secret consumed by + the Scaleway provider in External Secrets Operator. + properties: + name: + description: Name of the Secret. + type: string + required: + - name + type: object + description: + description: |- + Description is the description applied to the Scaleway secret on + create/update. + type: string + projectID: + description: ProjectID is the Scaleway Project UUID owning + the secret. + type: string + region: + description: Region is the Scaleway region for Secret Manager + (e.g. fr-par, nl-ams, pl-waw). + enum: + - fr-par + - nl-ams + - pl-waw + type: string + tags: + additionalProperties: + type: string + description: |- + Tags are key/value tags applied to the Scaleway secret. Scaleway + stores tags as a flat list of strings; the controller serialises + each entry as "key=value" on update. + type: object + required: + - authSecretRef + - projectID + - region + type: object type: object secretName: description: |- diff --git a/internal/secrets/factory.go b/internal/secrets/factory.go index 08df8a7..f8dfd04 100644 --- a/internal/secrets/factory.go +++ b/internal/secrets/factory.go @@ -21,11 +21,11 @@ import ( // 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") +var ErrNoBackendConfigured = errors.New("spec.secretBackend has no backend configured: set one of aws, kubernetes, infisical, scaleway") // 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") +var ErrMultipleBackendsConfigured = errors.New("spec.secretBackend has multiple backends configured: set exactly one of aws, kubernetes, infisical, scaleway") // Standard data keys for the Infisical Universal Auth bootstrap Secret. const ( @@ -33,6 +33,14 @@ const ( InfisicalAuthClientSecretKey = "clientSecret" ) +// Standard data keys for the Scaleway IAM API-key bootstrap Secret. +// Mirrors the shape ESO's Scaleway provider expects, so the same Secret +// can back both stores when a cluster has ESO + DBUO. +const ( + ScalewayAuthAccessKeyKey = "access_key" + ScalewayAuthSecretKeyKey = "secret_key" +) + // NewBackend selects and constructs a Backend implementation based on // which spec.secretBackend.* field is populated. Returns // ErrMultipleBackendsConfigured / ErrNoBackendConfigured if zero or @@ -55,6 +63,9 @@ func NewBackend(ctx context.Context, db *databasev1alpha1.Database, k8sClient cl if sb.Infisical != nil { count++ } + if sb.Scaleway != nil { + count++ + } if count == 0 { return nil, ErrNoBackendConfigured } @@ -95,6 +106,20 @@ func NewBackend(ctx context.Context, db *databasev1alpha1.Database, k8sClient cl auth, ), nil + case sb.Scaleway != nil: + if k8sClient == nil { + return nil, errors.New("scaleway secret backend requires a non-nil k8s client (to read the API-key Secret)") + } + auth, err := readScalewayAuth(ctx, k8sClient, db.Namespace, sb.Scaleway.AuthSecretRef.Name) + if err != nil { + return nil, err + } + return NewScalewayBackend( + sb.Scaleway.Region, + sb.Scaleway.ProjectID, + auth, + ) + default: return nil, ErrNoBackendConfigured } @@ -115,3 +140,19 @@ func readInfisicalAuth(ctx context.Context, k8sClient client.Client, namespace, } return InfisicalAuth{ClientID: clientID, ClientSecret: clientSecret}, nil } + +// readScalewayAuth reads the access_key/secret_key pair from the +// Kubernetes Secret referenced by spec.secretBackend.scaleway.authSecretRef. +// The Secret must live in the same namespace as the Database resource. +func readScalewayAuth(ctx context.Context, k8sClient client.Client, namespace, name string) (ScalewayAuth, error) { + var s corev1.Secret + if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &s); err != nil { + return ScalewayAuth{}, fmt.Errorf("failed to read scaleway auth secret %s/%s: %w", namespace, name, err) + } + accessKey := string(s.Data[ScalewayAuthAccessKeyKey]) + secretKey := string(s.Data[ScalewayAuthSecretKeyKey]) + if accessKey == "" || secretKey == "" { + return ScalewayAuth{}, fmt.Errorf("scaleway auth secret %s/%s missing %q or %q key", namespace, name, ScalewayAuthAccessKeyKey, ScalewayAuthSecretKeyKey) + } + return ScalewayAuth{AccessKey: accessKey, SecretKey: secretKey}, nil +} diff --git a/internal/secrets/scaleway.go b/internal/secrets/scaleway.go new file mode 100644 index 0000000..b767d5d --- /dev/null +++ b/internal/secrets/scaleway.go @@ -0,0 +1,323 @@ +/* +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" + "fmt" + "sort" + "strings" + + smapi "github.com/scaleway/scaleway-sdk-go/api/secret/v1beta1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +// ScalewayAuth represents the Scaleway IAM API key pair (access_key + +// secret_key) used to authenticate against Secret Manager. Sourced from a +// Kubernetes Secret in the cluster; reading it is the controller's +// responsibility, not this package's. +type ScalewayAuth struct { + AccessKey string + SecretKey string +} + +// ScalewaySMClient is the subset of the Scaleway Secret Manager SDK +// surface that ScalewayBackend uses. Defining it as an interface lets +// tests swap in an in-memory fake. +type ScalewaySMClient interface { + ListSecrets(req *smapi.ListSecretsRequest, opts ...scw.RequestOption) (*smapi.ListSecretsResponse, error) + CreateSecret(req *smapi.CreateSecretRequest, opts ...scw.RequestOption) (*smapi.Secret, error) + UpdateSecret(req *smapi.UpdateSecretRequest, opts ...scw.RequestOption) (*smapi.Secret, error) + DeleteSecret(req *smapi.DeleteSecretRequest, opts ...scw.RequestOption) error + CreateSecretVersion(req *smapi.CreateSecretVersionRequest, opts ...scw.RequestOption) (*smapi.SecretVersion, error) + AccessSecretVersion(req *smapi.AccessSecretVersionRequest, opts ...scw.RequestOption) (*smapi.AccessSecretVersionResponse, error) +} + +// ScalewayBackend stores generated database credentials in Scaleway +// Secret Manager. The DatabaseSecret JSON blob is stored verbatim as +// the version payload on a Scaleway Secret named after a sanitised +// version of the supplied name (slashes replaced with underscores so +// the result is a valid Scaleway Secret name). +// +// Each Update creates a new SecretVersion; the returned version string +// is the SecretVersion Revision (1-based monotonic). +type ScalewayBackend struct { + client ScalewaySMClient + region scw.Region + projectID string +} + +// NewScalewayBackend constructs a ScalewayBackend authenticated with +// the supplied IAM API key against the given region + project. +func NewScalewayBackend(region, projectID string, auth ScalewayAuth) (*ScalewayBackend, error) { + r, err := parseScalewayRegion(region) + if err != nil { + return nil, err + } + if projectID == "" { + return nil, errors.New("scaleway secret backend: projectID is required") + } + if auth.AccessKey == "" || auth.SecretKey == "" { + return nil, errors.New("scaleway secret backend: auth.AccessKey and auth.SecretKey are required") + } + + scwClient, err := scw.NewClient( + scw.WithAuth(auth.AccessKey, auth.SecretKey), + scw.WithDefaultRegion(r), + scw.WithDefaultProjectID(projectID), + ) + if err != nil { + return nil, fmt.Errorf("failed to construct scaleway client: %w", err) + } + + return &ScalewayBackend{ + client: smapi.NewAPI(scwClient), + region: r, + projectID: projectID, + }, nil +} + +// newScalewayBackendWithClient is used by tests to inject a fake client. +func newScalewayBackendWithClient(client ScalewaySMClient, region scw.Region, projectID string) *ScalewayBackend { + return &ScalewayBackend{client: client, region: region, projectID: projectID} +} + +// Compile-time check that *ScalewayBackend satisfies Backend. +var _ Backend = (*ScalewayBackend)(nil) + +// parseScalewayRegion validates and converts a region string. Empty is +// rejected — the controller path always provides one (CRD field is +// required + enum-validated). +func parseScalewayRegion(region string) (scw.Region, error) { + if region == "" { + return "", errors.New("scaleway secret backend: region is required") + } + r, err := scw.ParseRegion(region) + if err != nil { + return "", fmt.Errorf("invalid scaleway region %q: %w", region, err) + } + return r, nil +} + +// scalewayName normalises an arbitrary "secret name" (which may include +// slashes from the AWS-style rds// default) into a valid +// Scaleway Secret name. Scaleway accepts [A-Za-z0-9-_.] in names. +func scalewayName(name string) string { + n := strings.Trim(name, "/") + n = strings.ReplaceAll(n, "/", "_") + return n +} + +// locator returns a stable backend-specific identifier for the secret. +// Uses (region, projectID, sanitised-name) so it stays human-readable +// and is independent of the secret's UUID at lookup time. +func (b *ScalewayBackend) locator(name string) string { + return fmt.Sprintf("scaleway://%s/%s/%s", b.region, b.projectID, scalewayName(name)) +} + +// findSecret looks up a Scaleway Secret by name+project. Returns +// (*smapi.Secret, true, nil) on hit, (nil, false, nil) on miss. +func (b *ScalewayBackend) findSecret(ctx context.Context, name string) (*smapi.Secret, bool, error) { + n := scalewayName(name) + resp, err := b.client.ListSecrets(&smapi.ListSecretsRequest{ + Region: b.region, + ProjectID: &b.projectID, + Name: &n, + }, scw.WithContext(ctx)) + if err != nil { + return nil, false, fmt.Errorf("scaleway list-secrets failed: %w", err) + } + for _, s := range resp.Secrets { + if s.Name == n { + return s, true, nil + } + } + return nil, false, nil +} + +// Exists implements Backend. +func (b *ScalewayBackend) Exists(ctx context.Context, name string) (bool, error) { + _, ok, err := b.findSecret(ctx, name) + return ok, err +} + +// Get implements Backend. +func (b *ScalewayBackend) Get(ctx context.Context, name string) (*DatabaseSecret, error) { + s, ok, err := b.findSecret(ctx, name) + if err != nil { + return nil, err + } + if !ok { + return nil, &SecretNotFoundError{SecretName: b.locator(name)} + } + access, err := b.client.AccessSecretVersion(&smapi.AccessSecretVersionRequest{ + Region: b.region, + SecretID: s.ID, + Revision: "latest_enabled", + }, scw.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("scaleway access-secret-version failed: %w", err) + } + var dbSecret DatabaseSecret + if err := json.Unmarshal(access.Data, &dbSecret); err != nil { + return nil, fmt.Errorf("failed to unmarshal scaleway secret payload at %s: %w", b.locator(name), err) + } + return &dbSecret, nil +} + +// Create implements Backend. If a Secret with this name already exists +// (including soft-deleted state — Scaleway has no soft-delete window for +// SM, so this just means the resource is present), Create overwrites by +// adding a new version, matching the AWS / Kubernetes / Infisical +// "restore-on-create" behaviour the controller relies on. +func (b *ScalewayBackend) 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) + } + + existing, ok, err := b.findSecret(ctx, name) + if err != nil { + return "", "", err + } + + var secretID string + if ok { + secretID = existing.ID + // Update tags + description in case they drifted. + desiredTags := tagsToScalewaySlice(tags) + updateReq := &smapi.UpdateSecretRequest{ + Region: b.region, + SecretID: secretID, + Tags: &desiredTags, + } + if description != "" { + d := description + updateReq.Description = &d + } + if _, err := b.client.UpdateSecret(updateReq, scw.WithContext(ctx)); err != nil { + return "", "", fmt.Errorf("scaleway update-secret (description/tags) failed: %w", err) + } + } else { + createReq := &smapi.CreateSecretRequest{ + Region: b.region, + ProjectID: b.projectID, + Name: scalewayName(name), + Tags: tagsToScalewaySlice(tags), + } + if description != "" { + d := description + createReq.Description = &d + } + created, err := b.client.CreateSecret(createReq, scw.WithContext(ctx)) + if err != nil { + return "", "", fmt.Errorf("scaleway create-secret failed: %w", err) + } + secretID = created.ID + } + + version, err := b.client.CreateSecretVersion(&smapi.CreateSecretVersionRequest{ + Region: b.region, + SecretID: secretID, + Data: payload, + }, scw.WithContext(ctx)) + if err != nil { + return "", "", fmt.Errorf("scaleway create-secret-version failed: %w", err) + } + return b.locator(name), fmt.Sprintf("%d", version.Revision), nil +} + +// Update implements Backend. +func (b *ScalewayBackend) Update(ctx context.Context, name string, secret *DatabaseSecret, template string) (string, error) { + s, ok, err := b.findSecret(ctx, name) + if err != nil { + return "", err + } + if !ok { + return "", &SecretNotFoundError{SecretName: b.locator(name)} + } + payload, err := secret.ToJSONWithTemplate(template) + if err != nil { + return "", fmt.Errorf("failed to marshal database secret: %w", err) + } + version, err := b.client.CreateSecretVersion(&smapi.CreateSecretVersionRequest{ + Region: b.region, + SecretID: s.ID, + Data: payload, + }, scw.WithContext(ctx)) + if err != nil { + return "", fmt.Errorf("scaleway create-secret-version failed: %w", err) + } + return fmt.Sprintf("%d", version.Revision), nil +} + +// Delete implements Backend. Scaleway Secret Manager has no soft-delete +// recovery window, so forceDelete is ignored. Deleting a non-existent +// secret is not an error. +func (b *ScalewayBackend) Delete(ctx context.Context, name string, forceDelete bool) error { + s, ok, err := b.findSecret(ctx, name) + if err != nil { + return err + } + if !ok { + return nil + } + if err := b.client.DeleteSecret(&smapi.DeleteSecretRequest{ + Region: b.region, + SecretID: s.ID, + }, scw.WithContext(ctx)); err != nil { + return fmt.Errorf("scaleway delete-secret failed: %w", err) + } + return nil +} + +// Locator implements Backend. +func (b *ScalewayBackend) Locator(ctx context.Context, name string) (string, error) { + return b.locator(name), nil +} + +// SyncTags implements Backend by replacing the secret's tag set with +// the desired one. Scaleway's UpdateSecret accepts the full tag list, +// so this is a single API call. +func (b *ScalewayBackend) SyncTags(ctx context.Context, name string, desired map[string]string) error { + s, ok, err := b.findSecret(ctx, name) + if err != nil { + return err + } + if !ok { + return &SecretNotFoundError{SecretName: b.locator(name)} + } + tags := tagsToScalewaySlice(desired) + if _, err := b.client.UpdateSecret(&smapi.UpdateSecretRequest{ + Region: b.region, + SecretID: s.ID, + Tags: &tags, + }, scw.WithContext(ctx)); err != nil { + return fmt.Errorf("scaleway update-secret (tags) failed: %w", err) + } + return nil +} + +// tagsToScalewaySlice flattens a map into Scaleway's []string tag +// shape. Each entry serialises as "key=value"; an empty map yields an +// empty (non-nil) slice so UpdateSecret clears tags rather than +// leaving the existing set untouched. +func tagsToScalewaySlice(tags map[string]string) []string { + out := make([]string, 0, len(tags)) + for k, v := range tags { + if v == "" { + out = append(out, k) + } else { + out = append(out, fmt.Sprintf("%s=%s", k, v)) + } + } + sort.Strings(out) + return out +} diff --git a/internal/secrets/scaleway_test.go b/internal/secrets/scaleway_test.go new file mode 100644 index 0000000..6815464 --- /dev/null +++ b/internal/secrets/scaleway_test.go @@ -0,0 +1,400 @@ +/* +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" + + smapi "github.com/scaleway/scaleway-sdk-go/api/secret/v1beta1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +// fakeScalewayClient is an in-memory implementation of ScalewaySMClient +// used in tests. It models the subset of API behaviour the backend +// relies on: name+project lookup, version creation, latest_enabled +// access, tag/description update, and delete. +type fakeScalewayClient struct { + secrets map[string]*smapi.Secret // keyed by secret ID + versions map[string][][]byte // keyed by secret ID; index = revision-1 + createErr error + listErr error + updateErr error + deleteErr error + createVerErr error + accessVerErr error + idCounter int + createdCalls int + versionCalls int + updatedCalls int + deletedCalls int + listCalls int +} + +func newFakeScalewayClient() *fakeScalewayClient { + return &fakeScalewayClient{ + secrets: map[string]*smapi.Secret{}, + versions: map[string][][]byte{}, + } +} + +func (f *fakeScalewayClient) ListSecrets(req *smapi.ListSecretsRequest, _ ...scw.RequestOption) (*smapi.ListSecretsResponse, error) { + f.listCalls++ + if f.listErr != nil { + return nil, f.listErr + } + out := &smapi.ListSecretsResponse{} + for _, s := range f.secrets { + if req.Name != nil && s.Name != *req.Name { + continue + } + if req.ProjectID != nil && s.ProjectID != *req.ProjectID { + continue + } + out.Secrets = append(out.Secrets, s) + } + out.TotalCount = uint64(len(out.Secrets)) + return out, nil +} + +func (f *fakeScalewayClient) CreateSecret(req *smapi.CreateSecretRequest, _ ...scw.RequestOption) (*smapi.Secret, error) { + f.createdCalls++ + if f.createErr != nil { + return nil, f.createErr + } + f.idCounter++ + id := "secret-id-" + itoa(f.idCounter) + desc := "" + if req.Description != nil { + desc = *req.Description + } + s := &smapi.Secret{ + ID: id, + ProjectID: req.ProjectID, + Name: req.Name, + Tags: append([]string(nil), req.Tags...), + Description: &desc, + Region: req.Region, + } + f.secrets[id] = s + return s, nil +} + +func (f *fakeScalewayClient) UpdateSecret(req *smapi.UpdateSecretRequest, _ ...scw.RequestOption) (*smapi.Secret, error) { + f.updatedCalls++ + if f.updateErr != nil { + return nil, f.updateErr + } + s, ok := f.secrets[req.SecretID] + if !ok { + return nil, errors.New("not found") + } + if req.Tags != nil { + s.Tags = append([]string(nil), (*req.Tags)...) + } + if req.Description != nil { + d := *req.Description + s.Description = &d + } + return s, nil +} + +func (f *fakeScalewayClient) DeleteSecret(req *smapi.DeleteSecretRequest, _ ...scw.RequestOption) error { + f.deletedCalls++ + if f.deleteErr != nil { + return f.deleteErr + } + delete(f.secrets, req.SecretID) + delete(f.versions, req.SecretID) + return nil +} + +func (f *fakeScalewayClient) CreateSecretVersion(req *smapi.CreateSecretVersionRequest, _ ...scw.RequestOption) (*smapi.SecretVersion, error) { + f.versionCalls++ + if f.createVerErr != nil { + return nil, f.createVerErr + } + if _, ok := f.secrets[req.SecretID]; !ok { + return nil, errors.New("secret not found") + } + f.versions[req.SecretID] = append(f.versions[req.SecretID], append([]byte(nil), req.Data...)) + rev := uint32(len(f.versions[req.SecretID])) + return &smapi.SecretVersion{Revision: rev, SecretID: req.SecretID}, nil +} + +func (f *fakeScalewayClient) AccessSecretVersion(req *smapi.AccessSecretVersionRequest, _ ...scw.RequestOption) (*smapi.AccessSecretVersionResponse, error) { + if f.accessVerErr != nil { + return nil, f.accessVerErr + } + versions, ok := f.versions[req.SecretID] + if !ok || len(versions) == 0 { + return nil, errors.New("no versions") + } + // "latest" / "latest_enabled" / numeric — we treat them all as latest in the fake. + return &smapi.AccessSecretVersionResponse{ + SecretID: req.SecretID, + Data: append([]byte(nil), versions[len(versions)-1]...), + }, nil +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + digits := []byte{} + for i > 0 { + digits = append([]byte{byte('0' + i%10)}, digits...) + i /= 10 + } + return string(digits) +} + +func newScalewayTestBackend(client ScalewaySMClient) *ScalewayBackend { + return newScalewayBackendWithClient(client, scw.RegionFrPar, "proj-uuid") +} + +func sampleDBSecret() *DatabaseSecret { + return &DatabaseSecret{ + DBHost: "pg.example.com", + DBPort: 5432, + DBName: "appdb", + DBUsername: "appuser", + DBPassword: "s3cret", + DatabaseURL: "postgresql://appuser:s3cret@pg.example.com:5432/appdb", + Engine: "postgres", + } +} + +func TestScalewayBackend_Create_NewSecret(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + tags := map[string]string{"env": "test", "owner": "dbuo"} + + loc, ver, err := b.Create(context.Background(), "rds/postgres/foo", "test", sampleDBSecret(), tags, "") + if err != nil { + t.Fatalf("Create: %v", err) + } + if loc != "scaleway://fr-par/proj-uuid/rds_postgres_foo" { + t.Errorf("locator = %q", loc) + } + if ver != "1" { + t.Errorf("version = %q, want 1", ver) + } + if fake.createdCalls != 1 || fake.versionCalls != 1 { + t.Errorf("expected 1 create + 1 version, got %d/%d", fake.createdCalls, fake.versionCalls) + } + // Tags serialised as key=value, sorted. + for _, s := range fake.secrets { + if got, want := strings.Join(s.Tags, ","), "env=test,owner=dbuo"; got != want { + t.Errorf("tags = %q, want %q", got, want) + } + } +} + +func TestScalewayBackend_Create_RestoreOnExisting(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + + // Pre-seed a Secret to simulate an already-existing entry. + if _, _, err := b.Create(context.Background(), "rds/postgres/foo", "first", sampleDBSecret(), nil, ""); err != nil { + t.Fatalf("seed: %v", err) + } + + // Second Create on the same name must NOT create a second Secret; + // it should add a new version + push tag/description updates. + updatedTags := map[string]string{"env": "prod"} + _, ver, err := b.Create(context.Background(), "rds/postgres/foo", "second", sampleDBSecret(), updatedTags, "") + if err != nil { + t.Fatalf("Create-on-existing: %v", err) + } + if ver != "2" { + t.Errorf("version = %q, want 2", ver) + } + if fake.createdCalls != 1 { + t.Errorf("CreateSecret called %d times, want 1 (second call should reuse existing)", fake.createdCalls) + } + if fake.updatedCalls != 1 { + t.Errorf("UpdateSecret called %d times, want 1 (description+tags refresh)", fake.updatedCalls) + } + if fake.versionCalls != 2 { + t.Errorf("CreateSecretVersion called %d times, want 2", fake.versionCalls) + } +} + +func TestScalewayBackend_Get_RoundTrip(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + + want := sampleDBSecret() + if _, _, err := b.Create(context.Background(), "myapp", "", want, nil, ""); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := b.Get(context.Background(), "myapp") + if err != nil { + t.Fatalf("Get: %v", err) + } + // DatabaseURL/Engine aren't round-tripped (they're derived); spot-check the persisted fields. + if got.DBHost != want.DBHost || got.DBPassword != want.DBPassword || got.DBPort != want.DBPort { + t.Errorf("round-trip mismatch: %+v vs %+v", got, want) + } +} + +func TestScalewayBackend_Get_NotFound(t *testing.T) { + b := newScalewayTestBackend(newFakeScalewayClient()) + _, err := b.Get(context.Background(), "missing") + var nf *SecretNotFoundError + if !errors.As(err, &nf) { + t.Fatalf("expected SecretNotFoundError, got %v", err) + } +} + +func TestScalewayBackend_Update_NewVersion(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + if _, _, err := b.Create(context.Background(), "x", "", sampleDBSecret(), nil, ""); err != nil { + t.Fatalf("Create: %v", err) + } + rotated := sampleDBSecret() + rotated.DBPassword = "rotated" + ver, err := b.Update(context.Background(), "x", rotated, "") + if err != nil { + t.Fatalf("Update: %v", err) + } + if ver != "2" { + t.Errorf("version = %q, want 2", ver) + } + got, err := b.Get(context.Background(), "x") + if err != nil { + t.Fatalf("Get post-update: %v", err) + } + if got.DBPassword != "rotated" { + t.Errorf("password = %q, want rotated", got.DBPassword) + } +} + +func TestScalewayBackend_Update_NotFound(t *testing.T) { + b := newScalewayTestBackend(newFakeScalewayClient()) + _, err := b.Update(context.Background(), "missing", sampleDBSecret(), "") + var nf *SecretNotFoundError + if !errors.As(err, &nf) { + t.Fatalf("expected SecretNotFoundError, got %v", err) + } +} + +func TestScalewayBackend_Delete(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + if _, _, err := b.Create(context.Background(), "x", "", sampleDBSecret(), nil, ""); err != nil { + t.Fatalf("Create: %v", err) + } + if err := b.Delete(context.Background(), "x", false); err != nil { + t.Fatalf("Delete: %v", err) + } + if fake.deletedCalls != 1 { + t.Errorf("DeleteSecret called %d times, want 1", fake.deletedCalls) + } + if ok, _ := b.Exists(context.Background(), "x"); ok { + t.Errorf("secret still exists post-delete") + } + // Delete on non-existent → no error. + if err := b.Delete(context.Background(), "missing", false); err != nil { + t.Errorf("Delete on missing: %v", err) + } +} + +func TestScalewayBackend_SyncTags(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + initial := map[string]string{"a": "1"} + if _, _, err := b.Create(context.Background(), "x", "", sampleDBSecret(), initial, ""); err != nil { + t.Fatalf("Create: %v", err) + } + desired := map[string]string{"b": "2", "c": "3"} + if err := b.SyncTags(context.Background(), "x", desired); err != nil { + t.Fatalf("SyncTags: %v", err) + } + for _, s := range fake.secrets { + got := strings.Join(s.Tags, ",") + want := "b=2,c=3" + if got != want { + t.Errorf("tags after SyncTags = %q, want %q", got, want) + } + } +} + +func TestScalewayBackend_SyncTags_NotFound(t *testing.T) { + b := newScalewayTestBackend(newFakeScalewayClient()) + err := b.SyncTags(context.Background(), "missing", map[string]string{"a": "1"}) + var nf *SecretNotFoundError + if !errors.As(err, &nf) { + t.Fatalf("expected SecretNotFoundError, got %v", err) + } +} + +func TestScalewayBackend_NameSanitisation(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + if _, _, err := b.Create(context.Background(), "/rds/postgres/myapp/", "", sampleDBSecret(), nil, ""); err != nil { + t.Fatalf("Create: %v", err) + } + for _, s := range fake.secrets { + if s.Name != "rds_postgres_myapp" { + t.Errorf("sanitised name = %q, want rds_postgres_myapp", s.Name) + } + } +} + +func TestScalewayBackend_NewBackend_Validation(t *testing.T) { + cases := []struct { + name string + region string + proj string + auth ScalewayAuth + errSub string + }{ + {"empty region", "", "p", ScalewayAuth{AccessKey: "a", SecretKey: "s"}, "region is required"}, + {"invalid region", "mars-1", "p", ScalewayAuth{AccessKey: "a", SecretKey: "s"}, "invalid scaleway region"}, + {"empty project", "fr-par", "", ScalewayAuth{AccessKey: "a", SecretKey: "s"}, "projectID is required"}, + {"empty auth", "fr-par", "p", ScalewayAuth{}, "AccessKey and auth.SecretKey"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewScalewayBackend(tc.region, tc.proj, tc.auth) + if err == nil || !strings.Contains(err.Error(), tc.errSub) { + t.Errorf("err = %v, want substring %q", err, tc.errSub) + } + }) + } +} + +// Sanity-check the JSON shape produced by Create matches what +// downstream consumers (e.g. ExternalSecrets templates) would expect: +// the same DatabaseSecret JSON the AWS / K8s / Infisical backends +// store. +func TestScalewayBackend_StoredPayloadIsDatabaseSecretJSON(t *testing.T) { + fake := newFakeScalewayClient() + b := newScalewayTestBackend(fake) + if _, _, err := b.Create(context.Background(), "x", "", sampleDBSecret(), nil, ""); err != nil { + t.Fatalf("Create: %v", err) + } + for id := range fake.versions { + raw := fake.versions[id][0] + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("stored payload is not valid JSON: %v", err) + } + for _, k := range []string{"DB_HOST", "DB_PORT", "DB_NAME", "DB_USERNAME", "DB_PASSWORD", "POSTGRES_URL"} { + if _, ok := m[k]; !ok { + t.Errorf("stored payload missing key %q: %v", k, m) + } + } + } +}