diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index f72f3fb..3b3cb0a 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -7,6 +7,7 @@ rules: - "" resources: - configmaps + - secrets verbs: - create - delete @@ -33,15 +34,6 @@ rules: - get - list - watch -- apiGroups: - - "" - resources: - - secrets - verbs: - - create - - get - - list - - watch - apiGroups: - "" resources: diff --git a/cmd/main.go b/cmd/main.go index 699153c..0b112d8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -46,6 +46,7 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" "github.com/deco-sites/decofile-operator/internal/build" + "github.com/deco-sites/decofile-operator/internal/buildsecrets" "github.com/deco-sites/decofile-operator/internal/controller" "github.com/deco-sites/decofile-operator/internal/valkey" webhookv1 "github.com/deco-sites/decofile-operator/internal/webhook/v1" @@ -81,6 +82,8 @@ func main() { var valkeyAdminPassword string var valkeyResyncPeriod time.Duration var valkeyWatchFailover bool + var buildSecretsBackend string + var buildSecretsResyncPeriod time.Duration flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -114,6 +117,12 @@ func main() { os.Getenv("VALKEY_WATCH_FAILOVER") != "false", "Subscribe to Sentinel +switch-master events and trigger immediate ACL resync on failover. "+ "Enabled by default when VALKEY_SENTINEL_URLS is set. Set VALKEY_WATCH_FAILOVER=false to disable.") + flag.StringVar(&buildSecretsBackend, "build-secrets-backend", + getEnvOrDefault("BUILD_SECRETS_BACKEND", "aws"), + "Backend for tenant build-secrets sync. Supported: aws | disabled.") + flag.DurationVar(&buildSecretsResyncPeriod, "build-secrets-resync-period", + parseDuration(os.Getenv("BUILD_SECRETS_RESYNC_PERIOD"), controller.DefaultBuildSecretsResyncPeriod), + "How often to re-fetch upstream build-secrets even when nothing changed in the cluster.") opts := zap.Options{ Development: false, } @@ -267,6 +276,28 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Namespace") os.Exit(1) } + + buildSecretsSource, err := newBuildSecretsSource(context.Background(), buildSecretsBackend) + if err != nil { + setupLog.Error(err, "unable to initialize build-secrets source", "backend", buildSecretsBackend) + os.Exit(1) + } + if buildSecretsSource != nil { + bsReconciler := &controller.BuildSecretsReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Source: buildSecretsSource, + ResyncPeriod: buildSecretsResyncPeriod, + } + if err := bsReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "BuildSecrets") + os.Exit(1) + } + setupLog.Info("Build-secrets sync enabled", "backend", buildSecretsBackend, "resyncPeriod", buildSecretsResyncPeriod) + } else { + setupLog.Info("Build-secrets sync disabled (BUILD_SECRETS_BACKEND=disabled)") + } + // Start Sentinel failover watcher if enabled and Sentinel is configured. // leaderElectedRunnable ensures only the active leader subscribes — prevents // redundant TriggerResyncAll calls from non-leader replicas. @@ -411,3 +442,18 @@ func getEnvOrDefault(key, defaultVal string) string { } return defaultVal } + +// newBuildSecretsSource picks an upstream secret backend for the +// BuildSecretsReconciler. `disabled` returns (nil, nil) so callers can +// skip wiring the reconciler entirely — useful for local dev and for +// clusters where this feature is not yet enabled. +func newBuildSecretsSource(ctx context.Context, backend string) (buildsecrets.Source, error) { + switch backend { + case "aws": + return buildsecrets.NewAWSSource(ctx) + case "disabled", "": + return nil, nil + default: + return nil, fmt.Errorf("unknown build-secrets backend %q (supported: aws | disabled)", backend) + } +} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 879d81d..0700ea7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -8,6 +8,7 @@ rules: - "" resources: - configmaps + - secrets verbs: - create - delete @@ -34,15 +35,6 @@ rules: - get - list - watch -- apiGroups: - - "" - resources: - - secrets - verbs: - - create - - get - - list - - watch - apiGroups: - "" resources: diff --git a/go.mod b/go.mod index 4dbf225..7b5b185 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,10 @@ go 1.24.0 require ( github.com/andybalholm/brotli v1.2.0 - github.com/aws/aws-sdk-go-v2 v1.36.3 + github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.29.14 - github.com/aws/aws-sdk-go-v2/credentials v1.17.67 - github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 + github.com/go-logr/logr v1.4.3 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.1 @@ -23,20 +23,17 @@ require ( require ( cel.dev/expr v0.24.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect - github.com/aws/smithy-go v1.22.2 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/blendle/zapdriver v1.3.1 // indirect @@ -49,7 +46,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.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.21.0 // indirect diff --git a/go.sum b/go.sum index b731f34..66013cc 100644 --- a/go.sum +++ b/go.sum @@ -4,42 +4,34 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 h1:jIiopHEV22b4yQP2q36Y0OmwLbsxNWdWwfZRR5QRRO4= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 h1:JUGKqUnJHbXpS8uyuICP/zpQ+vXUIXW2zTEqjMLCqrY= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7/go.mod h1:l/cqI7ujYqBuTR6Ll13d9/gG/uUdlVzJ1UDltEEBTOo= github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= diff --git a/internal/buildsecrets/aws_source.go b/internal/buildsecrets/aws_source.go new file mode 100644 index 0000000..d07ff01 --- /dev/null +++ b/internal/buildsecrets/aws_source.go @@ -0,0 +1,82 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package buildsecrets + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" +) + +// awsSecretsManagerAPI captures the AWS Secrets Manager surface this +// package uses. Defined so tests can swap a mock without depending on +// the real SDK client. +type awsSecretsManagerAPI interface { + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + +// AWSSource resolves keys against AWS Secrets Manager. Credentials come +// from the ambient environment (Pod Identity / IRSA in-cluster; default +// chain locally). Region is taken from the AWS_REGION env var or the +// default chain — usually set by the EKS Pod Identity webhook. +type AWSSource struct { + api awsSecretsManagerAPI +} + +// NewAWSSource builds a Source backed by the live Secrets Manager +// client. The reconciler keeps the same instance for its lifetime. +func NewAWSSource(ctx context.Context) (*AWSSource, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("load AWS config: %w", err) + } + return &AWSSource{api: secretsmanager.NewFromConfig(cfg)}, nil +} + +// Get fetches the JSON-encoded secret at `key` and decodes it into a +// flat map. A missing key returns (nil, false, nil) — the natural +// "not provisioned yet" state for tenants that have opted in but not +// supplied data. +func (s *AWSSource) Get(ctx context.Context, key string) (map[string]string, bool, error) { + out, err := s.api.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(key), + }) + if err != nil { + var nfe *smtypes.ResourceNotFoundException + if errors.As(err, &nfe) { + return nil, false, nil + } + return nil, false, fmt.Errorf("get secret %q: %w", key, err) + } + + if out.SecretString == nil { + // Binary secrets are not supported — builds inject env vars, + // not files, so a JSON object of strings is the only shape. + return nil, false, fmt.Errorf("secret %q has no SecretString (binary secret?)", key) + } + + var data map[string]string + if err := json.Unmarshal([]byte(*out.SecretString), &data); err != nil { + return nil, false, fmt.Errorf("parse %q as JSON object of strings: %w", key, err) + } + // Reject JSON `null` (Unmarshal leaves the map nil without erroring). + // An empty object {} is fine and yields a non-nil empty map. + if data == nil { + return nil, false, fmt.Errorf("secret %q payload is JSON null; expected an object of strings", key) + } + return data, true, nil +} diff --git a/internal/buildsecrets/aws_source_test.go b/internal/buildsecrets/aws_source_test.go new file mode 100644 index 0000000..29a4791 --- /dev/null +++ b/internal/buildsecrets/aws_source_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package buildsecrets + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" +) + +type mockSMClient struct { + out *secretsmanager.GetSecretValueOutput + err error +} + +func (m *mockSMClient) GetSecretValue(_ context.Context, _ *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + return m.out, m.err +} + +func TestAWSSourceGet_Found(t *testing.T) { + src := &AWSSource{api: &mockSMClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`{"DENO_AUTH_TOKENS":"github_pat_xxx@raw.githubusercontent.com","FOO":"bar"}`), + }, + }} + + data, exists, err := src.Get(context.Background(), "sites/acme/build") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !exists { + t.Fatal("exists = false, want true") + } + if data["DENO_AUTH_TOKENS"] != "github_pat_xxx@raw.githubusercontent.com" { + t.Fatalf("DENO_AUTH_TOKENS = %q", data["DENO_AUTH_TOKENS"]) + } + if data["FOO"] != "bar" { + t.Fatalf("FOO = %q", data["FOO"]) + } +} + +func TestAWSSourceGet_NotFoundIsNotAnError(t *testing.T) { + src := &AWSSource{api: &mockSMClient{ + err: &smtypes.ResourceNotFoundException{}, + }} + + data, exists, err := src.Get(context.Background(), "sites/acme/build") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if exists { + t.Fatal("exists = true on missing key, want false") + } + if data != nil { + t.Fatalf("data = %v, want nil", data) + } +} + +func TestAWSSourceGet_BinaryRejected(t *testing.T) { + src := &AWSSource{api: &mockSMClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretBinary: []byte{0x01, 0x02}, + }, + }} + + _, _, err := src.Get(context.Background(), "sites/acme/build") + if err == nil { + t.Fatal("expected error for binary secret, got nil") + } +} + +func TestAWSSourceGet_InvalidJSON(t *testing.T) { + src := &AWSSource{api: &mockSMClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`not json`), + }, + }} + + _, _, err := src.Get(context.Background(), "sites/acme/build") + if err == nil { + t.Fatal("expected JSON parse error, got nil") + } +} + +func TestAWSSourceGet_NullJSONRejected(t *testing.T) { + src := &AWSSource{api: &mockSMClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`null`), + }, + }} + + _, _, err := src.Get(context.Background(), "sites/acme/build") + if err == nil { + t.Fatal("expected error for null payload, got nil") + } +} + +func TestAWSSourceGet_EmptyObjectAccepted(t *testing.T) { + src := &AWSSource{api: &mockSMClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`{}`), + }, + }} + + data, exists, err := src.Get(context.Background(), "sites/acme/build") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !exists { + t.Fatal("empty object should be treated as existing") + } + if data == nil { + t.Fatal("empty object should yield non-nil empty map") + } + if len(data) != 0 { + t.Fatalf("data should be empty, got %v", data) + } +} + +func TestAWSSourceGet_OtherErrorPropagates(t *testing.T) { + sentinel := errors.New("boom") + src := &AWSSource{api: &mockSMClient{err: sentinel}} + + _, _, err := src.Get(context.Background(), "sites/acme/build") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, sentinel) { + t.Fatalf("error did not wrap sentinel: %v", err) + } +} diff --git a/internal/buildsecrets/secret.go b/internal/buildsecrets/secret.go new file mode 100644 index 0000000..8274759 --- /dev/null +++ b/internal/buildsecrets/secret.go @@ -0,0 +1,137 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package buildsecrets + +import ( + "context" + "fmt" + "maps" + "reflect" + + corev1 "k8s.io/api/core/v1" + "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" +) + +// Sync reconciles the K8s Secret `build-secrets` in `namespace` against +// the upstream Source for `site`. Four cases: +// +// 1. Upstream missing + no local Secret: no-op. +// 2. Upstream missing + local Secret owned by operator: delete locally. +// 3. Upstream present + no local Secret: create with operator labels. +// 4. Upstream present + local Secret owned by operator: update if data +// drifted. +// +// In all cases where a local Secret exists *without* operator labels, +// returns ErrNotOwned and makes no changes. +func Sync(ctx context.Context, c client.Client, namespace, site string, src Source) error { + data, exists, err := src.Get(ctx, fmt.Sprintf(KeyTemplate, site)) + if err != nil { + return fmt.Errorf("source.Get: %w", err) + } + + existing := &corev1.Secret{} + getErr := c.Get(ctx, types.NamespacedName{Name: SecretName, Namespace: namespace}, existing) + switch { + case errors.IsNotFound(getErr): + existing = nil + case getErr != nil: + return fmt.Errorf("get secret: %w", getErr) + } + + if !exists { + if existing == nil { + return nil + } + if !isManagedByUs(existing) { + return ErrNotOwned + } + if err := c.Delete(ctx, existing); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("delete secret: %w", err) + } + return nil + } + + desired := newSecret(namespace, data) + + if existing == nil { + if err := c.Create(ctx, desired); err != nil { + return fmt.Errorf("create secret: %w", err) + } + return nil + } + + if !isManagedByUs(existing) { + return ErrNotOwned + } + + if !dataChanged(existing.Data, desired.Data) { + return nil + } + + existing.Data = desired.Data + existing.StringData = nil + existing.Labels = maps.Clone(desired.Labels) + if err := c.Update(ctx, existing); err != nil { + return fmt.Errorf("update secret: %w", err) + } + return nil +} + +// Remove deletes the operator-managed K8s Secret in `namespace`. +// Refuses (ErrNotOwned) if the Secret lacks operator labels. +// Idempotent on NotFound. +func Remove(ctx context.Context, c client.Client, namespace string) error { + existing := &corev1.Secret{} + err := c.Get(ctx, types.NamespacedName{Name: SecretName, Namespace: namespace}, existing) + if errors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("get secret: %w", err) + } + if !isManagedByUs(existing) { + return ErrNotOwned + } + if err := c.Delete(ctx, existing); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("delete secret: %w", err) + } + return nil +} + +func newSecret(namespace string, data map[string]string) *corev1.Secret { + out := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecretName, + Namespace: namespace, + Labels: map[string]string{ + ManagedByLabel: "operator", + FeatureLabel: "build-secrets", + }, + }, + Data: make(map[string][]byte, len(data)), + Type: corev1.SecretTypeOpaque, + } + for k, v := range data { + out.Data[k] = []byte(v) + } + return out +} + +func isManagedByUs(s *corev1.Secret) bool { + return s.Labels[ManagedByLabel] == "operator" && s.Labels[FeatureLabel] == "build-secrets" +} + +func dataChanged(current, desired map[string][]byte) bool { + return !reflect.DeepEqual(current, desired) +} diff --git a/internal/buildsecrets/secret_test.go b/internal/buildsecrets/secret_test.go new file mode 100644 index 0000000..7bb862c --- /dev/null +++ b/internal/buildsecrets/secret_test.go @@ -0,0 +1,247 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package buildsecrets + +import ( + "context" + "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" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// mockSource implements Source for tests without touching AWS. +type mockSource struct { + data map[string]string + exists bool + err error +} + +func (m *mockSource) Get(ctx context.Context, key string) (map[string]string, bool, error) { + if m.err != nil { + return nil, false, m.err + } + return m.data, m.exists, nil +} + +func newClient(t *testing.T, objs ...client.Object) client.Client { + t.Helper() + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() +} + +const testNamespace = "sites-acme" + +func getSecret(t *testing.T, c client.Client) *corev1.Secret { + t.Helper() + got := &corev1.Secret{} + if err := c.Get(context.Background(), types.NamespacedName{Name: SecretName, Namespace: testNamespace}, got); err != nil { + t.Fatalf("get secret: %v", err) + } + return got +} + +func TestSyncCreatesWhenUpstreamExists(t *testing.T) { + c := newClient(t) + src := &mockSource{exists: true, data: map[string]string{"DENO_AUTH_TOKENS": "github_pat_xxx@raw.githubusercontent.com"}} + + if err := Sync(context.Background(), c, "sites-acme", "acme", src); err != nil { + t.Fatalf("Sync: %v", err) + } + + got := getSecret(t, c) + if string(got.Data["DENO_AUTH_TOKENS"]) != "github_pat_xxx@raw.githubusercontent.com" { + t.Fatalf("data not written: %v", got.Data) + } + if got.Labels[ManagedByLabel] != "operator" || got.Labels[FeatureLabel] != "build-secrets" { + t.Fatalf("labels missing: %v", got.Labels) + } +} + +func TestSyncNoopWhenUpstreamMissing(t *testing.T) { + c := newClient(t) + src := &mockSource{exists: false} + + if err := Sync(context.Background(), c, "sites-acme", "acme", src); err != nil { + t.Fatalf("Sync: %v", err) + } + + got := &corev1.Secret{} + err := c.Get(context.Background(), types.NamespacedName{Name: SecretName, Namespace: "sites-acme"}, got) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected NotFound, got %v", err) + } +} + +func TestSyncUpdatesOnDrift(t *testing.T) { + managed := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecretName, + Namespace: "sites-acme", + Labels: map[string]string{ + ManagedByLabel: "operator", + FeatureLabel: "build-secrets", + }, + }, + Data: map[string][]byte{"OLD": []byte("value")}, + } + c := newClient(t, managed) + src := &mockSource{exists: true, data: map[string]string{"NEW": "value"}} + + if err := Sync(context.Background(), c, "sites-acme", "acme", src); err != nil { + t.Fatalf("Sync: %v", err) + } + + got := getSecret(t, c) + if _, ok := got.Data["OLD"]; ok { + t.Fatal("old key not removed") + } + if string(got.Data["NEW"]) != "value" { + t.Fatalf("new key not written: %v", got.Data) + } +} + +func TestSyncIdempotentWhenDataMatches(t *testing.T) { + managed := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecretName, + Namespace: "sites-acme", + Labels: map[string]string{ + ManagedByLabel: "operator", + FeatureLabel: "build-secrets", + }, + ResourceVersion: "999", + }, + Data: map[string][]byte{"FOO": []byte("bar")}, + } + c := newClient(t, managed) + src := &mockSource{exists: true, data: map[string]string{"FOO": "bar"}} + + if err := Sync(context.Background(), c, "sites-acme", "acme", src); err != nil { + t.Fatalf("Sync: %v", err) + } + + got := getSecret(t, c) + if got.ResourceVersion != "999" { + t.Fatalf("ResourceVersion changed (write should have been skipped): %s", got.ResourceVersion) + } +} + +func TestSyncDeletesWhenUpstreamRemoved(t *testing.T) { + managed := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecretName, + Namespace: "sites-acme", + Labels: map[string]string{ + ManagedByLabel: "operator", + FeatureLabel: "build-secrets", + }, + }, + Data: map[string][]byte{"FOO": []byte("bar")}, + } + c := newClient(t, managed) + src := &mockSource{exists: false} + + if err := Sync(context.Background(), c, "sites-acme", "acme", src); err != nil { + t.Fatalf("Sync: %v", err) + } + + got := &corev1.Secret{} + err := c.Get(context.Background(), types.NamespacedName{Name: SecretName, Namespace: "sites-acme"}, got) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected Secret deleted, got err = %v", err) + } +} + +func TestSyncRefusesUnownedSecret(t *testing.T) { + unowned := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecretName, + Namespace: "sites-acme", + }, + Data: map[string][]byte{"USER_TOKEN": []byte("hand-crafted")}, + } + c := newClient(t, unowned) + src := &mockSource{exists: true, data: map[string]string{"FROM_SM": "value"}} + + err := Sync(context.Background(), c, "sites-acme", "acme", src) + if !errors.Is(err, ErrNotOwned) { + t.Fatalf("expected ErrNotOwned, got %v", err) + } + + got := getSecret(t, c) + if string(got.Data["USER_TOKEN"]) != "hand-crafted" { + t.Fatalf("unowned secret data was mutated: %v", got.Data) + } + if _, ok := got.Data["FROM_SM"]; ok { + t.Fatal("operator wrote into unowned secret") + } +} + +func TestRemoveDeletesManagedSecret(t *testing.T) { + managed := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecretName, + Namespace: "sites-acme", + Labels: map[string]string{ + ManagedByLabel: "operator", + FeatureLabel: "build-secrets", + }, + }, + } + c := newClient(t, managed) + + if err := Remove(context.Background(), c, "sites-acme"); err != nil { + t.Fatalf("Remove: %v", err) + } + + got := &corev1.Secret{} + err := c.Get(context.Background(), types.NamespacedName{Name: SecretName, Namespace: "sites-acme"}, got) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected NotFound, got %v", err) + } +} + +func TestRemoveRefusesUnowned(t *testing.T) { + unowned := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecretName, + Namespace: "sites-acme", + }, + } + c := newClient(t, unowned) + + err := Remove(context.Background(), c, "sites-acme") + if !errors.Is(err, ErrNotOwned) { + t.Fatalf("expected ErrNotOwned, got %v", err) + } + + // Secret still there + got := getSecret(t, c) + if got.Name != SecretName { + t.Fatal("unowned secret was deleted") + } +} + +func TestRemoveIdempotentOnNotFound(t *testing.T) { + c := newClient(t) + if err := Remove(context.Background(), c, "sites-acme"); err != nil { + t.Fatalf("Remove on empty: %v", err) + } +} diff --git a/internal/buildsecrets/source.go b/internal/buildsecrets/source.go new file mode 100644 index 0000000..cb2de0a --- /dev/null +++ b/internal/buildsecrets/source.go @@ -0,0 +1,56 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +// Package buildsecrets owns the operator's per-tenant build-time secret +// sync. Reconciler keeps a K8s Secret named `build-secrets` in opted-in +// site namespaces aligned with an upstream secret backend (AWS Secrets +// Manager today; other backends slot in by implementing Source). +package buildsecrets + +import ( + "context" + "errors" +) + +const ( + // SecretName is the K8s Secret consumed by the builder Job via + // envFrom (admin PR #3201 — `optional: true` so absence is a no-op). + SecretName = "build-secrets" + + // ManagedByLabel + FeatureLabel mark Secrets the operator created so + // it knows what is safe to update or delete. A Secret without these + // labels is treated as user-managed and left alone. + ManagedByLabel = "deco.sites/managed-by" + FeatureLabel = "deco.sites/feature" + + // KeyTemplate is the path convention in the upstream backend. AWS + // Secrets Manager stores `sites//build` as a JSON object whose + // keys/values land verbatim in the K8s Secret data. + KeyTemplate = "sites/%s/build" +) + +// ErrNotOwned signals the K8s Secret `build-secrets` exists in the +// namespace without the operator's labels. Sync and Remove refuse to +// touch it so we don't trample on a Secret a human created by hand. +var ErrNotOwned = errors.New("build-secrets Secret exists without operator labels; refusing to manage it") + +// Source abstracts the upstream secret backend behind a tiny shape that +// hides AWS-specifics from the reconciler. Implementations: +// +// - AWSSource (this package): AWS Secrets Manager via aws-sdk-go-v2 +// - future GCPSource / VaultSource: same interface, different SDK +// +// Get returns (data, true, nil) when the upstream key exists; (nil, +// false, nil) when it does not — *not* an error, just "no upstream +// data yet" (normal state for un-provisioned tenants). Network or +// permission failures bubble up via the error return. +type Source interface { + Get(ctx context.Context, key string) (data map[string]string, exists bool, err error) +} diff --git a/internal/controller/buildsecrets_controller.go b/internal/controller/buildsecrets_controller.go new file mode 100644 index 0000000..ebe0f33 --- /dev/null +++ b/internal/controller/buildsecrets_controller.go @@ -0,0 +1,136 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package controller + +import ( + "context" + "errors" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deco-sites/decofile-operator/internal/buildsecrets" +) + +const ( + buildSecretsAnnotation = "deco.sites/build-secrets-managed" + buildSecretsAnnotationValue = "enabled" + + // DefaultBuildSecretsResyncPeriod is the safety-net interval at which + // the reconciler re-fetches the upstream backend even when nothing + // changed in the cluster — picks up out-of-band edits to AWS Secrets + // Manager. Configurable via BUILD_SECRETS_RESYNC_PERIOD. + DefaultBuildSecretsResyncPeriod = 15 * time.Minute +) + +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete + +// BuildSecretsReconciler keeps the K8s Secret `build-secrets` in each +// opted-in site namespace aligned with the upstream backend (AWS +// Secrets Manager today; see buildsecrets.Source for the abstraction). +// +// Opt-in is the annotation `deco.sites/build-secrets-managed: enabled` +// on the Namespace. The reconciler is fully event-driven (no polling) +// but requeues at ResyncPeriod as a safety net for upstream edits the +// operator did not observe (manual aws CLI rotation, etc.). +// +// # State machine +// +// annotation off → ensure no operator-managed Secret exists +// annotation on, no upstream → no Secret (status: upstream-missing) +// annotation on, upstream → Secret created/updated with data +// +// # Force-sync recipes +// +// Re-fetch ONE site from the upstream immediately (instead of waiting +// for ResyncPeriod): +// +// kubectl annotate ns sites- \ +// deco.sites/build-secrets-sync=$(date +%s) --overwrite +// +// Re-fetch ALL managed sites at once: +// +// kubectl annotate ns -l deco.sites/build-secrets-managed=enabled \ +// deco.sites/build-secrets-sync=$(date +%s) --overwrite +type BuildSecretsReconciler struct { + client.Client + Scheme *runtime.Scheme + Source buildsecrets.Source + ResyncPeriod time.Duration +} + +func (r *BuildSecretsReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx).WithName("buildsecrets").WithValues("namespace", req.Name) + + // Strip the `sites-` prefix inline; we deliberately avoid the + // valkey-specific helper that filters reserved usernames. + site := strings.TrimPrefix(req.Name, siteNamespacePrefix) + if site == req.Name || site == "" { + return ctrl.Result{}, nil + } + + ns := &corev1.Namespace{} + if err := r.Get(ctx, req.NamespacedName, ns); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + optedIn := ns.Annotations[buildSecretsAnnotation] == buildSecretsAnnotationValue + if !optedIn || !ns.DeletionTimestamp.IsZero() { + if err := buildsecrets.Remove(ctx, r.Client, ns.Name); err != nil { + if errors.Is(err, buildsecrets.ErrNotOwned) { + log.Info("Secret exists without operator labels — leaving it alone", "site", site) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + if err := buildsecrets.Sync(ctx, r.Client, ns.Name, site, r.Source); err != nil { + if errors.Is(err, buildsecrets.ErrNotOwned) { + log.Info("Skipping unowned Secret build-secrets — operator will not adopt it", "site", site) + return ctrl.Result{RequeueAfter: r.ResyncPeriod}, nil + } + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: r.ResyncPeriod}, nil +} + +func (r *BuildSecretsReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Map Secret events back to the parent Namespace so deletions or + // out-of-band edits trigger a re-reconcile that restores state. + secretToNamespace := handler.EnqueueRequestsFromMapFunc( + func(_ context.Context, obj client.Object) []reconcile.Request { + if obj.GetName() != buildsecrets.SecretName { + return nil + } + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{Name: obj.GetNamespace()}}, + } + }, + ) + + return ctrl.NewControllerManagedBy(mgr). + Named("buildsecrets"). + For(&corev1.Namespace{}). + Watches(&corev1.Secret{}, secretToNamespace). + WithOptions(controller.Options{MaxConcurrentReconciles: 4}). + Complete(r) +}