From 2a9e782c250170972eb4a944b858954380afadd7 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 8 May 2026 12:08:29 +0400 Subject: [PATCH 1/4] fix(rds): set StorageEncrypted=true on mysql + postgres instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS RDS instances default to UNENCRYPTED storage at rest unless `StorageEncrypted: pulumi.Bool(true)` is explicitly set. Both `pkg/clouds/pulumi/aws/rds_mysql.go:108` and `pkg/clouds/pulumi/aws/rds_postgres.go:101` were missing the field, which is a CIS AWS Foundations Benchmark RDS.3 violation. Setting it now is also a one-shot decision: encryption cannot be toggled on an existing RDS instance without snapshot-restore. `StorageEncrypted: sdk.Bool(true)` uses the AWS-managed RDS KMS key (`aws/rds`) by default. A future change can supply a customer-managed `KmsKeyId` if SC adopts CMK-per-stack. Surfaced by simple-container-com/actions PR #7 (new `go-aws-rds-no-storage-encryption` semgrep rule) — analysing this codebase was what produced the rule. Signed-off-by: Dmitrii Creed --- pkg/clouds/pulumi/aws/rds_mysql.go | 1 + pkg/clouds/pulumi/aws/rds_postgres.go | 1 + 2 files changed, 2 insertions(+) diff --git a/pkg/clouds/pulumi/aws/rds_mysql.go b/pkg/clouds/pulumi/aws/rds_mysql.go index d3f48080..95dc0110 100644 --- a/pkg/clouds/pulumi/aws/rds_mysql.go +++ b/pkg/clouds/pulumi/aws/rds_mysql.go @@ -118,6 +118,7 @@ func RdsMysql(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params Username: sdk.String(lo.If(dbConfig.Username != "", dbConfig.Username).Else("root")), Password: sdk.String(lo.If(dbConfig.Password != "", dbConfig.Password).Else("root")), SkipFinalSnapshot: sdk.Bool(true), + StorageEncrypted: sdk.Bool(true), Tags: tags, }, opts...) if err != nil { diff --git a/pkg/clouds/pulumi/aws/rds_postgres.go b/pkg/clouds/pulumi/aws/rds_postgres.go index 7b3084fe..94b57dd3 100644 --- a/pkg/clouds/pulumi/aws/rds_postgres.go +++ b/pkg/clouds/pulumi/aws/rds_postgres.go @@ -111,6 +111,7 @@ func RdsPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, par Username: sdk.String(lo.If(postgresCfg.Username != "", postgresCfg.Username).Else("postgres")), Password: sdk.String(lo.If(postgresCfg.Password != "", postgresCfg.Password).Else("postgres")), SkipFinalSnapshot: sdk.Bool(true), + StorageEncrypted: sdk.Bool(true), Tags: tags, }, opts...) if err != nil { From fd6dc56be261a3c66554b6d03ecd8765b6abdabc Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 8 May 2026 12:50:38 +0400 Subject: [PATCH 2/4] rds: ignore-changes on storageEncrypted to protect existing customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, the StorageEncrypted: sdk.Bool(true) added in this PR triggers a REPLACEMENT of any pre-existing unencrypted RDS instance when the customer next runs `sc deploy` after upgrading SC. AWS RDS's `storage_encrypted` is immutable — Pulumi can only enforce a change by destroying the instance and creating a new one, which means data loss + downtime. Adding `sdk.IgnoreChanges([]string{"storageEncrypted"})` on both mysql and postgres RDS resource opts so: * NEW instances created after this lands → get StorageEncrypted: true * EXISTING pre-encryption instances → state-vs-code drift on this field is silently ignored; pulumi does NOT propose a replacement Customers who want to encrypt an existing instance must follow the standard AWS migration path: 1. Take a snapshot of the unencrypted instance. 2. Copy the snapshot with encryption enabled (CopyDBSnapshot, KmsKeyId set). 3. Restore from the encrypted snapshot to a new instance. 4. Cut traffic over (DNS / app config), then delete the old. 5. (If using Pulumi state) re-import the new instance into the SC stack so subsequent diffs match. Documented inline as a comment on the IgnoreChanges line so future maintainers don't accidentally remove it and break customer fleets. Signed-off-by: Dmitrii Creed --- pkg/clouds/pulumi/aws/rds_mysql.go | 11 +++++++++++ pkg/clouds/pulumi/aws/rds_postgres.go | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/pkg/clouds/pulumi/aws/rds_mysql.go b/pkg/clouds/pulumi/aws/rds_mysql.go index 95dc0110..172c2ab3 100644 --- a/pkg/clouds/pulumi/aws/rds_mysql.go +++ b/pkg/clouds/pulumi/aws/rds_mysql.go @@ -36,6 +36,17 @@ func RdsMysql(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params opts := []sdk.ResourceOption{ sdk.Provider(params.Provider), + // AWS RDS `storage_encrypted` is IMMUTABLE — changing it from + // false to true triggers a full replacement of the instance, + // which destroys the underlying volume and all its data. New + // instances created from now on get encryption (see + // `StorageEncrypted: sdk.Bool(true)` below); existing + // pre-encryption instances are left alone via this ignore- + // changes so an SC upgrade does not nuke a customer's database. + // Customers who want to encrypt an existing instance should + // snapshot → copy snapshot with encryption enabled → restore + // from the encrypted snapshot, then re-import into Pulumi. + sdk.IgnoreChanges([]string{"storageEncrypted"}), } tags := pApi.BuildTagsFromStackParams(*input.StackParams).ToAWSTags() diff --git a/pkg/clouds/pulumi/aws/rds_postgres.go b/pkg/clouds/pulumi/aws/rds_postgres.go index 94b57dd3..e2f201ef 100644 --- a/pkg/clouds/pulumi/aws/rds_postgres.go +++ b/pkg/clouds/pulumi/aws/rds_postgres.go @@ -36,6 +36,17 @@ func RdsPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, par opts := []sdk.ResourceOption{ sdk.Provider(params.Provider), + // AWS RDS `storage_encrypted` is IMMUTABLE — changing it from + // false to true triggers a full replacement of the instance, + // which destroys the underlying volume and all its data. New + // instances created from now on get encryption (see + // `StorageEncrypted: sdk.Bool(true)` below); existing + // pre-encryption instances are left alone via this ignore- + // changes so an SC upgrade does not nuke a customer's database. + // Customers who want to encrypt an existing instance should + // snapshot → copy snapshot with encryption enabled → restore + // from the encrypted snapshot, then re-import into Pulumi. + sdk.IgnoreChanges([]string{"storageEncrypted"}), } tags := pApi.BuildTagsFromStackParams(*input.StackParams).ToAWSTags() From b88be10c3a1e2602a17d3293dfc730b16747b03a Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 8 May 2026 18:48:47 +0400 Subject: [PATCH 3/4] rds: make StorageEncrypted opt-in (preserve legacy behaviour by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR #239 review feedback (Ilya Sadykov): the previous version forced encryption on every NEW RDS instance after upgrade — a backwards- compatibility break for customers who hadn't asked for it. Even with the IgnoreChanges safety net for existing instances, brand-new stacks created post-upgrade would silently get encryption. Refactor to make encryption an explicit opt-in: * MysqlConfig and PostgresConfig get a new optional field: StorageEncrypted *bool `json:"storageEncrypted,omitempty"` * Pulumi resource uses `sdk.Bool(lo.FromPtr(cfg.StorageEncrypted))`. nil → false (legacy default, AWS-side default = unencrypted) true → encrypted (uses AWS-managed `aws/rds` KMS key by default) false → unencrypted (caller asked for it explicitly; respect that) * `sdk.IgnoreChanges([]string{"storageEncrypted"})` is RETAINED on the resource opts. It's still load-bearing: once a customer flips the bit on a stack with a pre-existing unencrypted instance, the AWS-side `storage_encrypted` attribute is immutable, so without IgnoreChanges Pulumi would propose a destructive replacement. Same change in both rds_mysql.go and rds_postgres.go on both the api- config side and the pulumi-resource side. Tests added in pkg/clouds/aws/rds_storage_encrypted_test.go covering all three states (omitted / explicit-true / explicit-false) for both mysql and postgres. Verifies: - Round-trips through `api.ConvertConfig` with the right pointer semantics (omitted YAML → nil pointer in struct). - The resolved `lo.FromPtr` flag matches the documented contract, which is what gets passed to `rds.NewInstance`. Migration path for existing unencrypted instances stays the same and is documented inline next to the IgnoreChanges line: snapshot → encrypted-copy → restore → re-import. Future-proofing: if more RDS hardening fields land (MultiAZ, DeletionProtection, BackupRetentionPeriod...), each new field should follow the same opt-in shape until the count grows enough to justify a `SchemaVersion`-style bundle (mongodb-atlas precedent in pkg/clouds/mongodb/mongodb.go). Signed-off-by: Dmitrii Creed --- pkg/clouds/aws/rds_mysql.go | 13 +++++++++++++ pkg/clouds/aws/rds_postgres.go | 13 +++++++++++++ pkg/clouds/pulumi/aws/rds_mysql.go | 27 ++++++++++++++++----------- pkg/clouds/pulumi/aws/rds_postgres.go | 20 ++++++++------------ 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/pkg/clouds/aws/rds_mysql.go b/pkg/clouds/aws/rds_mysql.go index d5f5d004..85f16bd0 100644 --- a/pkg/clouds/aws/rds_mysql.go +++ b/pkg/clouds/aws/rds_mysql.go @@ -18,6 +18,19 @@ type MysqlConfig struct { Password string `json:"password" yaml:"password"` DatabaseName *string `json:"databaseName" yaml:"databaseName"` EngineName *string `json:"engineName,omitempty" yaml:"engineName,omitempty"` + // StorageEncrypted opts into AWS-side encryption-at-rest for the + // underlying EBS volume. When unset (nil), the instance is created + // with the AWS default — currently UNENCRYPTED — preserving exact + // behaviour for stacks that pre-date this field. Set `true` to opt + // the resource into encryption (uses the AWS-managed `aws/rds` KMS + // key by default). + // + // AWS RDS `storage_encrypted` is IMMUTABLE post-creation. Toggling + // this field on an existing unencrypted instance does NOT migrate + // data — it is silenced via `pulumi.IgnoreChanges` to prevent a + // destructive replacement. To convert an existing unencrypted RDS + // to encrypted, snapshot → encrypted-copy → restore → re-import. + StorageEncrypted *bool `json:"storageEncrypted,omitempty" yaml:"storageEncrypted,omitempty"` } func ReadRdsMysqlConfig(config *api.Config) (api.Config, error) { diff --git a/pkg/clouds/aws/rds_postgres.go b/pkg/clouds/aws/rds_postgres.go index caf41112..646ee92b 100644 --- a/pkg/clouds/aws/rds_postgres.go +++ b/pkg/clouds/aws/rds_postgres.go @@ -18,6 +18,19 @@ type PostgresConfig struct { Password string `json:"password" yaml:"password"` DatabaseName *string `json:"databaseName" yaml:"databaseName"` InitSQL *string `json:"initSQL,omitempty" yaml:"initSQL,omitempty"` + // StorageEncrypted opts into AWS-side encryption-at-rest for the + // underlying EBS volume. When unset (nil), the instance is created + // with the AWS default — currently UNENCRYPTED — preserving exact + // behaviour for stacks that pre-date this field. Set `true` to opt + // the resource into encryption (uses the AWS-managed `aws/rds` KMS + // key by default). + // + // AWS RDS `storage_encrypted` is IMMUTABLE post-creation. Toggling + // this field on an existing unencrypted instance does NOT migrate + // data — it is silenced via `pulumi.IgnoreChanges` to prevent a + // destructive replacement. To convert an existing unencrypted RDS + // to encrypted, snapshot → encrypted-copy → restore → re-import. + StorageEncrypted *bool `json:"storageEncrypted,omitempty" yaml:"storageEncrypted,omitempty"` } func ReadRdsPostgresConfig(config *api.Config) (api.Config, error) { diff --git a/pkg/clouds/pulumi/aws/rds_mysql.go b/pkg/clouds/pulumi/aws/rds_mysql.go index 172c2ab3..1cccf32c 100644 --- a/pkg/clouds/pulumi/aws/rds_mysql.go +++ b/pkg/clouds/pulumi/aws/rds_mysql.go @@ -36,16 +36,20 @@ func RdsMysql(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params opts := []sdk.ResourceOption{ sdk.Provider(params.Provider), - // AWS RDS `storage_encrypted` is IMMUTABLE — changing it from + // AWS RDS `storage_encrypted` is IMMUTABLE — flipping it from // false to true triggers a full replacement of the instance, - // which destroys the underlying volume and all its data. New - // instances created from now on get encryption (see - // `StorageEncrypted: sdk.Bool(true)` below); existing - // pre-encryption instances are left alone via this ignore- - // changes so an SC upgrade does not nuke a customer's database. - // Customers who want to encrypt an existing instance should - // snapshot → copy snapshot with encryption enabled → restore - // from the encrypted snapshot, then re-import into Pulumi. + // which destroys the underlying volume and all its data. + // + // The `StorageEncrypted` config field below is opt-in (nil = + // keep AWS default = unencrypted, preserving pre-2026.5 SC + // behaviour). But even with opt-in semantics on the SC side, + // once a customer flips the bit on a stack with a pre-existing + // unencrypted instance, Pulumi would still propose a destructive + // replacement. This `IgnoreChanges` silences that drift so a + // config change does NOT nuke the database. Customers who want + // to genuinely migrate an existing unencrypted RDS to encrypted + // must do it out-of-band: snapshot → encrypted-copy → restore → + // re-import. Documented on `MysqlConfig.StorageEncrypted`. sdk.IgnoreChanges([]string{"storageEncrypted"}), } @@ -129,8 +133,9 @@ func RdsMysql(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params Username: sdk.String(lo.If(dbConfig.Username != "", dbConfig.Username).Else("root")), Password: sdk.String(lo.If(dbConfig.Password != "", dbConfig.Password).Else("root")), SkipFinalSnapshot: sdk.Bool(true), - StorageEncrypted: sdk.Bool(true), - Tags: tags, + // nil → false (legacy default). See MysqlConfig.StorageEncrypted. + StorageEncrypted: sdk.Bool(lo.FromPtr(dbConfig.StorageEncrypted)), + Tags: tags, }, opts...) if err != nil { return nil, errors.Wrapf(err, "failed to create rds mysql instance") diff --git a/pkg/clouds/pulumi/aws/rds_postgres.go b/pkg/clouds/pulumi/aws/rds_postgres.go index e2f201ef..a912cbe9 100644 --- a/pkg/clouds/pulumi/aws/rds_postgres.go +++ b/pkg/clouds/pulumi/aws/rds_postgres.go @@ -36,16 +36,11 @@ func RdsPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, par opts := []sdk.ResourceOption{ sdk.Provider(params.Provider), - // AWS RDS `storage_encrypted` is IMMUTABLE — changing it from - // false to true triggers a full replacement of the instance, - // which destroys the underlying volume and all its data. New - // instances created from now on get encryption (see - // `StorageEncrypted: sdk.Bool(true)` below); existing - // pre-encryption instances are left alone via this ignore- - // changes so an SC upgrade does not nuke a customer's database. - // Customers who want to encrypt an existing instance should - // snapshot → copy snapshot with encryption enabled → restore - // from the encrypted snapshot, then re-import into Pulumi. + // See rds_mysql.go for the full rationale. Same shape applies + // to postgres: opt-in via `PostgresConfig.StorageEncrypted` + // (nil = AWS default = unencrypted, pre-2026.5 SC behaviour), + // and `IgnoreChanges` silences drift so flipping the bit on + // an existing stack does not trigger a destructive replacement. sdk.IgnoreChanges([]string{"storageEncrypted"}), } @@ -122,8 +117,9 @@ func RdsPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, par Username: sdk.String(lo.If(postgresCfg.Username != "", postgresCfg.Username).Else("postgres")), Password: sdk.String(lo.If(postgresCfg.Password != "", postgresCfg.Password).Else("postgres")), SkipFinalSnapshot: sdk.Bool(true), - StorageEncrypted: sdk.Bool(true), - Tags: tags, + // nil → false (legacy default). See PostgresConfig.StorageEncrypted. + StorageEncrypted: sdk.Bool(lo.FromPtr(postgresCfg.StorageEncrypted)), + Tags: tags, }, opts...) if err != nil { return nil, errors.Wrapf(err, "failed to create rds postgres instance") From 3ef442a194cb61c92f47b87ea7c0fb4f14a1b779 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 8 May 2026 18:49:08 +0400 Subject: [PATCH 4/4] rds: add unit tests for StorageEncrypted config plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests in pkg/clouds/aws/rds_storage_encrypted_test.go covering all three states (omitted / explicit-true / explicit-false) for both MysqlConfig and PostgresConfig. Verifies: - Round-trips through `api.ConvertConfig` with correct pointer semantics (omitted YAML → nil pointer). - The resolved `lo.FromPtr` flag matches the documented contract (nil → false / true → true / false → false), which is exactly what gets passed to `rds.NewInstance(StorageEncrypted: ...)`. Tests hit the same code path the pulumi handlers use, so any future rename / serialization breakage surfaces here without needing a Pulumi mock-resource harness. Replacement-safety + IgnoreChanges behaviour is integration-only and documented separately on the PR. Should have been in the previous commit. Signed-off-by: Dmitrii Creed --- pkg/clouds/aws/rds_storage_encrypted_test.go | 165 +++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 pkg/clouds/aws/rds_storage_encrypted_test.go diff --git a/pkg/clouds/aws/rds_storage_encrypted_test.go b/pkg/clouds/aws/rds_storage_encrypted_test.go new file mode 100644 index 00000000..7494cea8 --- /dev/null +++ b/pkg/clouds/aws/rds_storage_encrypted_test.go @@ -0,0 +1,165 @@ +package aws + +import ( + "testing" + + . "github.com/onsi/gomega" + "github.com/samber/lo" + + "github.com/simple-container-com/api/pkg/api" +) + +// Tests for the opt-in `StorageEncrypted` field on MysqlConfig / +// PostgresConfig. Three states matter: +// +// 1. omitted from YAML / JSON → field stays nil → `lo.FromPtr(nil)` +// collapses to `false`, which preserves pre-2026.5 SC behaviour +// for stacks created without the field. +// 2. explicit `true` → encrypted instance. +// 3. explicit `false` → still unencrypted (caller asked for it +// explicitly; we don't second-guess). +// +// The actual replacement-safety guarantee for existing instances comes +// from `pulumi.IgnoreChanges([]{"storageEncrypted"})` on the resource +// opts (see pkg/clouds/pulumi/aws/rds_{mysql,postgres}.go) and is +// covered by integration / e2e tests, not here. + +func TestReadRdsMysqlConfig_StorageEncrypted(t *testing.T) { + RegisterTestingT(t) + + tests := []struct { + name string + config *api.Config + wantSet bool + wantVal bool + }{ + { + name: "omitted → nil (legacy default, encryption off)", + config: &api.Config{Config: map[string]any{ + "instanceClass": "db.t3.micro", + "engineVersion": "8.0", + "username": "root", + "password": "root", + }}, + wantSet: false, + }, + { + name: "explicit true → encrypted", + config: &api.Config{Config: map[string]any{ + "instanceClass": "db.t3.micro", + "engineVersion": "8.0", + "username": "root", + "password": "root", + "storageEncrypted": true, + }}, + wantSet: true, + wantVal: true, + }, + { + name: "explicit false → still unencrypted", + config: &api.Config{Config: map[string]any{ + "instanceClass": "db.t3.micro", + "engineVersion": "8.0", + "username": "root", + "password": "root", + "storageEncrypted": false, + }}, + wantSet: true, + wantVal: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + out, err := ReadRdsMysqlConfig(tt.config) + Expect(err).To(BeNil()) + cfg, ok := out.Config.(*MysqlConfig) + Expect(ok).To(BeTrue()) + + if !tt.wantSet { + Expect(cfg.StorageEncrypted).To(BeNil(), + "unset field should round-trip as nil so `lo.FromPtr` resolves to false") + } else { + Expect(cfg.StorageEncrypted).ToNot(BeNil()) + Expect(*cfg.StorageEncrypted).To(Equal(tt.wantVal)) + } + + // `lo.FromPtr(nil)` is `false` — explicitly assert the + // resolved Pulumi flag matches the documented contract. + resolved := lo.FromPtr(cfg.StorageEncrypted) + expected := tt.wantSet && tt.wantVal + Expect(resolved).To(Equal(expected), + "resolved flag passed to `rds.NewInstance` must match nil → false / true → true / false → false") + }) + } +} + +func TestReadRdsPostgresConfig_StorageEncrypted(t *testing.T) { + RegisterTestingT(t) + + tests := []struct { + name string + config *api.Config + wantSet bool + wantVal bool + }{ + { + name: "omitted → nil (legacy default, encryption off)", + config: &api.Config{Config: map[string]any{ + "instanceClass": "db.t3.micro", + "engineVersion": "16", + "username": "postgres", + "password": "postgres", + }}, + wantSet: false, + }, + { + name: "explicit true → encrypted", + config: &api.Config{Config: map[string]any{ + "instanceClass": "db.t3.micro", + "engineVersion": "16", + "username": "postgres", + "password": "postgres", + "storageEncrypted": true, + }}, + wantSet: true, + wantVal: true, + }, + { + name: "explicit false → still unencrypted", + config: &api.Config{Config: map[string]any{ + "instanceClass": "db.t3.micro", + "engineVersion": "16", + "username": "postgres", + "password": "postgres", + "storageEncrypted": false, + }}, + wantSet: true, + wantVal: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + out, err := ReadRdsPostgresConfig(tt.config) + Expect(err).To(BeNil()) + cfg, ok := out.Config.(*PostgresConfig) + Expect(ok).To(BeTrue()) + + if !tt.wantSet { + Expect(cfg.StorageEncrypted).To(BeNil(), + "unset field should round-trip as nil so `lo.FromPtr` resolves to false") + } else { + Expect(cfg.StorageEncrypted).ToNot(BeNil()) + Expect(*cfg.StorageEncrypted).To(Equal(tt.wantVal)) + } + + resolved := lo.FromPtr(cfg.StorageEncrypted) + expected := tt.wantSet && tt.wantVal + Expect(resolved).To(Equal(expected), + "resolved flag passed to `rds.NewInstance` must match nil → false / true → true / false → false") + }) + } +}