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
5 changes: 5 additions & 0 deletions pkg/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions pkg/project/provider/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions pkg/project/provider/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
4 changes: 4 additions & 0 deletions pkg/project/provider/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
31 changes: 29 additions & 2 deletions pkg/project/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 9 additions & 1 deletion pkg/project/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
Expand Down
49 changes: 49 additions & 0 deletions platform/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading