diff --git a/pkg/project/project.go b/pkg/project/project.go index d3685a511f..0bf4ff9844 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -27,6 +27,10 @@ import ( "github.com/sst/sst/v3/pkg/runtime/worker" ) +type AppState struct { + PurgeOnRemove bool `json:"purgeOnRemove"` +} + type App struct { Name string `json:"name"` Stage string `json:"stage"` @@ -36,6 +40,7 @@ type App struct { Version string `json:"version"` Protect bool `json:"protect"` Watch []string `json:"watch"` + State *AppState `json:"state"` // Deprecated: Backend is now Home Backend string `json:"backend"` // Deprecated: RemovalPolicy is now Removal diff --git a/pkg/project/provider/aws.go b/pkg/project/provider/aws.go index 1ee95fc941..ccbc1831f5 100644 --- a/pkg/project/provider/aws.go +++ b/pkg/project/provider/aws.go @@ -691,6 +691,22 @@ func (a *AwsHome) getPassphrase(app string, stage string) (string, error) { return *result.Parameter.Value, nil } +func (a *AwsHome) removePassphrase(app, stage string) error { + ssmClient := ssm.NewFromConfig(a.provider.config) + + _, err := ssmClient.DeleteParameter(context.TODO(), &ssm.DeleteParameterInput{ + Name: aws.String(a.pathForPassphrase(app, stage)), + }) + if err != nil { + var pnf *ssmTypes.ParameterNotFound + if errors.As(err, &pnf) { + return nil + } + return err + } + return nil +} + func (a *AwsHome) setPassphrase(app, stage, passphrase string) error { ssmClient := ssm.NewFromConfig(a.provider.config) diff --git a/pkg/project/provider/cloudflare.go b/pkg/project/provider/cloudflare.go index 2f6cbf8a93..dfb4fb2925 100644 --- a/pkg/project/provider/cloudflare.go +++ b/pkg/project/provider/cloudflare.go @@ -169,6 +169,10 @@ func (c *CloudflareHome) removeData(kind, app, stage string) error { return nil } +func (c *CloudflareHome) removePassphrase(app, stage string) error { + return c.removeData("passphrase", app, stage) +} + // these should go into secrets manager once it's out of beta func (c *CloudflareHome) setPassphrase(app, stage string, passphrase string) error { return c.putData("passphrase", app, stage, bytes.NewReader([]byte(passphrase))) diff --git a/pkg/project/provider/local.go b/pkg/project/provider/local.go index e250b24a44..9f75473b10 100644 --- a/pkg/project/provider/local.go +++ b/pkg/project/provider/local.go @@ -65,6 +65,10 @@ func (l *LocalHome) removeData(key, app, stage string) error { return os.Remove(p) } +func (c *LocalHome) removePassphrase(app, stage string) error { + return c.removeData("passphrase", app, stage) +} + // these should go into secrets manager once it's out of beta func (c *LocalHome) setPassphrase(app, stage string, passphrase string) error { return c.putData("passphrase", app, stage, bytes.NewReader([]byte(passphrase))) diff --git a/pkg/project/provider/provider.go b/pkg/project/provider/provider.go index f2f1a8acb9..38c43af932 100644 --- a/pkg/project/provider/provider.go +++ b/pkg/project/provider/provider.go @@ -29,6 +29,7 @@ type Home interface { getPassphrase(app, stage string) (string, error) listStages(app string) ([]string, error) cleanup(key, app, stage string) error + removePassphrase(app, stage string) error info() (util.KeyValuePairs[string], error) } @@ -102,6 +103,18 @@ func Passphrase(backend Home, app, stage string) (string, error) { return "", err } + if passphrase != "" { + cache[app+stage] = passphrase + } + return passphrase, nil +} + +func PassphraseInit(backend Home, app, stage string) (string, error) { + passphrase, err := Passphrase(backend, app, stage) + if err != nil { + return "", err + } + if passphrase == "" { slog.Info("passphrase not found, setting passphrase", "app", app, "stage", stage) passphrase = flag.SST_PASSPHRASE @@ -117,9 +130,10 @@ func Passphrase(backend Home, app, stage string) (string, error) { if err != nil { return "", err } + + passphraseCache[backend][app+stage] = passphrase } - existingPassphrase, ok = cache[app+stage] return passphrase, nil } @@ -168,6 +182,19 @@ func Cleanup(backend Home, app, stage string) error { return nil } +func Purge(backend Home, app, stage string) error { + if err := backend.removeData("app", app, stage); err != nil { + return err + } + if err := backend.removeData("secret", app, stage); err != nil { + return err + } + if err := backend.removePassphrase(app, stage); err != nil { + return err + } + return nil +} + func GetSecrets(backend Home, app, stage string) (map[string]string, error) { if stage == "" { stage = "_fallback" @@ -348,7 +375,7 @@ func putData(backend Home, key, app, stage string, encrypt bool, data interface{ return err } if encrypt { - passphrase, err := Passphrase(backend, app, stage) + passphrase, err := PassphraseInit(backend, app, stage) if err != nil { return err } diff --git a/pkg/project/run.go b/pkg/project/run.go index a21c54280d..632fcf95da 100644 --- a/pkg/project/run.go +++ b/pkg/project/run.go @@ -70,7 +70,12 @@ func (p *Project) Run(ctx context.Context, input *StackInput) error { } defer workdir.Cleanup() - passphrase, err := provider.Passphrase(p.home, p.app.Name, p.app.Stage) + var passphrase string + if input.Command == "deploy" || input.Dev { + passphrase, err = provider.PassphraseInit(p.home, p.app.Name, p.app.Stage) + } else { + passphrase, err = provider.Passphrase(p.home, p.app.Name, p.app.Stage) + } if err != nil { return err } @@ -670,6 +675,9 @@ loop: if input.Command == "remove" && len(complete.Resources) == 0 { provider.Cleanup(p.home, p.app.Name, p.app.Stage) + if p.app.State != nil && p.app.State.PurgeOnRemove { + provider.Purge(p.home, p.app.Name, p.app.Stage) + } } log.Info("done running stack command", "resources", len(complete.Resources)) diff --git a/platform/src/config.ts b/platform/src/config.ts index c8297e4326..866d01387a 100644 --- a/platform/src/config.ts +++ b/platform/src/config.ts @@ -286,6 +286,55 @@ export interface App { * The paths are relative to the project root. */ watch?: string[]; + + /** + * Configure how your app's state is managed. + * + * The state keeps track of all your resources, secrets, and the encryption key. By default, + * this data is preserved via versioning even after `sst remove` so you can recover it if needed. + * + * @example + * + * For example, to fully remove the state file and encryption key: + * + * ```ts + * { + * state: { + * purgeOnRemove: input.stage !== "production" + * } + * } + * ``` + */ + state?: { + /** + * If set to `true`, running `sst remove` will fully remove all state associated + * with the stage once all resources have been successfully removed. + * + * This removes the state files, secrets and the encryption passphrase. + * + * :::caution + * This is irreversible. Once the state encryption key is deleted, secrets and all state versions will be + * unrecoverable. + * ::: + * + * :::tip + * Only enable this for ephemeral or development stages. + * ::: + * + * @default false + * + * @example + * + * ```ts + * { + * state: { + * purgeOnRemove: true + * } + * } + * ``` + */ + purgeOnRemove?: boolean; + }; } export interface AppInput {