Skip to content
Open
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
25 changes: 14 additions & 11 deletions pkg/clouds/aws/rds_mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ 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).
// StorageEncrypted controls AWS-side encryption-at-rest for the
// underlying EBS volume. When unset (nil), new instances default
// to ENCRYPTED (AWS-managed `aws/rds` KMS key), matching CIS-AWS
// Foundations RDS.3. Set `false` explicitly to opt out for legacy
// unencrypted stacks; set `true` to be explicit.
//
// 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.
// AWS RDS `storage_encrypted` is IMMUTABLE post-creation. The
// default flip is safe for existing instances because the
// `pulumi.IgnoreChanges` on the resource opts (see
// pkg/clouds/pulumi/aws/rds_mysql.go) silences storage_encrypted
// drift — Pulumi will not propose a destructive replacement when
// the spec value differs from the cloud-actual value. Customers
// who want to genuinely migrate an existing unencrypted RDS to
// encrypted must do it out-of-band: snapshot → encrypted-copy →
// restore → re-import.
StorageEncrypted *bool `json:"storageEncrypted,omitempty" yaml:"storageEncrypted,omitempty"`
}

Expand Down
25 changes: 14 additions & 11 deletions pkg/clouds/aws/rds_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ 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).
// StorageEncrypted controls AWS-side encryption-at-rest for the
// underlying EBS volume. When unset (nil), new instances default
// to ENCRYPTED (AWS-managed `aws/rds` KMS key), matching CIS-AWS
// Foundations RDS.3. Set `false` explicitly to opt out for legacy
// unencrypted stacks; set `true` to be explicit.
//
// 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.
// AWS RDS `storage_encrypted` is IMMUTABLE post-creation. The
// default flip is safe for existing instances because the
// `pulumi.IgnoreChanges` on the resource opts (see
// pkg/clouds/pulumi/aws/rds_postgres.go) silences storage_encrypted
// drift — Pulumi will not propose a destructive replacement when
// the spec value differs from the cloud-actual value. Customers
// who want to genuinely migrate an existing unencrypted RDS to
// encrypted must do it out-of-band: snapshot → encrypted-copy →
// restore → re-import.
StorageEncrypted *bool `json:"storageEncrypted,omitempty" yaml:"storageEncrypted,omitempty"`
}

Expand Down
43 changes: 23 additions & 20 deletions pkg/clouds/aws/rds_storage_encrypted_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,23 @@ import (
"github.com/simple-container-com/api/pkg/api"
)

// Tests for the opt-in `StorageEncrypted` field on MysqlConfig /
// PostgresConfig. Three states matter:
// Tests for the `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).
// 1. omitted from YAML / JSON → field stays nil →
// `lo.FromPtrOr(nil, true)` collapses to `true`, the secure
// default per CIS-AWS RDS.3.
// 2. explicit `true` → encrypted instance (same).
// 3. explicit `false` → unencrypted (caller asked for it explicitly;
// the secure default does not override their choice).
//
// 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.
// The replacement-safety guarantee for existing unencrypted instances
// comes from `pulumi.IgnoreChanges([]{"storageEncrypted"})` on the
// resource opts (see pkg/clouds/pulumi/aws/rds_{mysql,postgres}.go) —
// the default flip from nil→true does NOT propose a destructive
// replace, because Pulumi diffs against the recorded state value
// (which has storage_encrypted=false on legacy instances) rather than
// the new spec value. Covered by integration / e2e tests, not here.

func TestReadRdsMysqlConfig_StorageEncrypted(t *testing.T) {
RegisterTestingT(t)
Expand All @@ -34,7 +37,7 @@ func TestReadRdsMysqlConfig_StorageEncrypted(t *testing.T) {
wantVal bool
}{
{
name: "omitted → nil (legacy default, encryption off)",
name: "omitted → nil (secure default, encryption on)",
config: &api.Config{Config: map[string]any{
"instanceClass": "db.t3.micro",
"engineVersion": "8.0",
Expand Down Expand Up @@ -79,18 +82,18 @@ func TestReadRdsMysqlConfig_StorageEncrypted(t *testing.T) {

if !tt.wantSet {
Expect(cfg.StorageEncrypted).To(BeNil(),
"unset field should round-trip as nil so `lo.FromPtr` resolves to false")
"unset field should round-trip as nil so `lo.FromPtrOr(_, true)` resolves to true")
} 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
// `lo.FromPtrOr(nil, true)` is `true` — secure-by-default.
// nil → true / true → true / false → false.
resolved := lo.FromPtrOr(cfg.StorageEncrypted, true)
expected := !tt.wantSet || tt.wantVal
Expect(resolved).To(Equal(expected),
"resolved flag passed to `rds.NewInstance` must match nil → false / true → true / false → false")
"resolved flag passed to `rds.NewInstance` must match nil → true / true → true / false → false")
})
}
}
Expand All @@ -105,7 +108,7 @@ func TestReadRdsPostgresConfig_StorageEncrypted(t *testing.T) {
wantVal bool
}{
{
name: "omitted → nil (legacy default, encryption off)",
name: "omitted → nil (secure default, encryption on)",
config: &api.Config{Config: map[string]any{
"instanceClass": "db.t3.micro",
"engineVersion": "16",
Expand Down
29 changes: 17 additions & 12 deletions pkg/clouds/pulumi/aws/rds_mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,18 @@ func RdsMysql(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params
// 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`.
// New instances default to ENCRYPTED (CIS-AWS RDS.3); the
// resolved spec for `StorageEncrypted` is `true` whenever the
// caller didn't explicitly set `false` (see the call site
// below). For existing stacks that were created unencrypted,
// the default flip would otherwise propose a destructive
// replacement on the next `pulumi up`. `IgnoreChanges` silences
// that storage_encrypted drift so an upgrade of SC does NOT
// nuke the database — Pulumi treats the desired value as the
// recorded state value for the diff. 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"}),
}

Expand Down Expand Up @@ -133,8 +135,11 @@ 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),
// nil → false (legacy default). See MysqlConfig.StorageEncrypted.
StorageEncrypted: sdk.Bool(lo.FromPtr(dbConfig.StorageEncrypted)),
// nil → true (secure-by-default per CIS-AWS RDS.3). Existing
// unencrypted instances are protected from destructive
// replacement by `IgnoreChanges([]string{"storageEncrypted"})`
// in opts above. See MysqlConfig.StorageEncrypted.
StorageEncrypted: sdk.Bool(lo.FromPtrOr(dbConfig.StorageEncrypted, true)),
Tags: tags,
}, opts...)
if err != nil {
Expand Down
16 changes: 10 additions & 6 deletions pkg/clouds/pulumi/aws/rds_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ 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.
// to postgres: new instances default to ENCRYPTED via
// `PostgresConfig.StorageEncrypted` (nil = secure-by-default
// per CIS-AWS RDS.3), and `IgnoreChanges` silences drift so
// the default flip does not propose a destructive replacement
// on existing unencrypted stacks.
sdk.IgnoreChanges([]string{"storageEncrypted"}),
}

Expand Down Expand Up @@ -117,8 +118,11 @@ 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),
// nil → false (legacy default). See PostgresConfig.StorageEncrypted.
StorageEncrypted: sdk.Bool(lo.FromPtr(postgresCfg.StorageEncrypted)),
// nil → true (secure-by-default per CIS-AWS RDS.3). Existing
// unencrypted instances are protected from destructive
// replacement by `IgnoreChanges([]string{"storageEncrypted"})`
// in opts above. See PostgresConfig.StorageEncrypted.
StorageEncrypted: sdk.Bool(lo.FromPtrOr(postgresCfg.StorageEncrypted, true)),
Tags: tags,
}, opts...)
if err != nil {
Expand Down
Loading