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/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") + }) + } +} diff --git a/pkg/clouds/pulumi/aws/rds_mysql.go b/pkg/clouds/pulumi/aws/rds_mysql.go index d3f48080..1cccf32c 100644 --- a/pkg/clouds/pulumi/aws/rds_mysql.go +++ b/pkg/clouds/pulumi/aws/rds_mysql.go @@ -36,6 +36,21 @@ 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 — flipping it from + // false to true triggers a full replacement of the instance, + // 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"}), } tags := pApi.BuildTagsFromStackParams(*input.StackParams).ToAWSTags() @@ -118,7 +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), - 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 7b3084fe..a912cbe9 100644 --- a/pkg/clouds/pulumi/aws/rds_postgres.go +++ b/pkg/clouds/pulumi/aws/rds_postgres.go @@ -36,6 +36,12 @@ func RdsPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, par opts := []sdk.ResourceOption{ sdk.Provider(params.Provider), + // 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"}), } tags := pApi.BuildTagsFromStackParams(*input.StackParams).ToAWSTags() @@ -111,7 +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), - 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")