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
13 changes: 13 additions & 0 deletions pkg/clouds/aws/rds_mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions pkg/clouds/aws/rds_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
165 changes: 165 additions & 0 deletions pkg/clouds/aws/rds_storage_encrypted_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
19 changes: 18 additions & 1 deletion pkg/clouds/pulumi/aws/rds_mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
10 changes: 9 additions & 1 deletion pkg/clouds/pulumi/aws/rds_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
Loading