Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions api/v1alpha1/database_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions config/samples/database_v1alpha1_database_scaleway.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: |-
Expand Down
45 changes: 43 additions & 2 deletions internal/secrets/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,26 @@ 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 (
InfisicalAuthClientIDKey = "clientId"
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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Loading
Loading