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
90 changes: 72 additions & 18 deletions cmd/sst/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"
"time"

"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/sst/sst/v3/cmd/sst/cli"
"github.com/sst/sst/v3/cmd/sst/mosaic/ui"
"github.com/sst/sst/v3/internal/util"
Expand Down Expand Up @@ -302,18 +304,15 @@ var CmdState = &cli.Command{

target := c.Positional(0)
muts := state.Remove(target, checkpoint)
err = confirmMutations(muts)
if err != nil {
if err := confirmMutations(muts); err != nil {
return err
}

err = workdir.Import(checkpoint)
if err != nil {
if err := workdir.Import(checkpoint); err != nil {
return util.NewReadableError(err, "Could not import state")
}

err = workdir.Push(update.ID)
if err != nil {
if err := workdir.Push(update.ID); err != nil {
return err
}
ui.Success("Resource removed")
Expand All @@ -329,19 +328,20 @@ var CmdState = &cli.Command{
"",
"Sometimes, if something goes wrong with your app, or if the state was directly",
"edited, the state can become corrupted. This will cause your `sst deploy` command",
"to fail.",
"to fail with a `snapshot integrity` error.",
"",
"This command looks for the following issues and fixes them.",
"",
"1. Since the state is a list of resources, if one resource depends on another,",
" it needs to be listed after the one it depends on. This command finds resources",
" that depend on each other but are not ordered correctly and **reorders them**.",
"",
"2. If resource B depends on resource A, but resource A is not listed in the state,",
" it'll **remove the dependency**.",
"2. If resource B depends on resource A but resource A is not listed in the state,",
" it'll **remove the dangling reference**. This applies to all dependency types:",
" parent, dependencies, property dependencies, `deletedWith` and `replaceWith`.",
"",
"This command does this by going through all the resources in the state, fixing the",
"issues and updating the state.",
"3. If a child resource has a parent that no longer exists, the child is",
" **unparented** (its URN is rewritten and it becomes a top-level resource).",
"",
"You can run this for specific stages as well.",
"",
Expand Down Expand Up @@ -384,19 +384,24 @@ var CmdState = &cli.Command{
return util.NewReadableError(err, "Could not export state")
}

muts := state.Repair(checkpoint)
err = confirmMutations(muts)
passphrase, err := provider.Passphrase(p.Backend(), p.App().Name, p.App().Stage)
if err != nil {
return err
return util.NewReadableError(err, "Could not load passphrase")
}

err = workdir.Import(checkpoint)
result, err := state.Repair(c.Context, passphrase, checkpoint)
if err != nil {
return util.NewReadableError(err, err.Error())
}
if err := confirmRepair(result); err != nil {
return err
}

if err := workdir.Import(checkpoint); err != nil {
return util.NewReadableError(err, "Could not import state")
}

err = workdir.Push(update.ID)
if err != nil {
if err := workdir.Push(update.ID); err != nil {
return err
}
ui.Success("State repaired")
Expand All @@ -406,6 +411,19 @@ var CmdState = &cli.Command{
},
}

func confirmRepair(result state.RepairResult) error {
if result.IsEmpty() {
return util.NewReadableError(nil, "No changes needed")
}

fmt.Println("Modified:")
for _, p := range result.Pruned {
renderPruneResult(p)
}

return promptConfirm()
}

func confirmMutations(muts []state.Mutation) error {
if len(muts) == 0 {
return util.NewReadableError(nil, "No changes made")
Expand All @@ -423,7 +441,43 @@ func confirmMutations(muts []state.Mutation) error {
}
}

// prompt for confirmation to continue
fmt.Println()
fmt.Print("Do you want to commit these changes? (Y/n): ")
var response string
_, err := fmt.Scanln(&response)
if err != nil {
return util.NewReadableError(err, "failed to read user input")
}
if strings.ToLower(response) != "y" {
return util.NewReadableError(nil, "Abandoning changes")
}
return nil
}

func renderPruneResult(p deploy.PruneResult) {
if p.OldURN != p.NewURN {
fmt.Printf(" - %s → %s (unparented)\n", p.OldURN.Type().DisplayName(), p.OldURN.Name())
} else {
fmt.Printf(" - %s → %s\n", p.OldURN.Type().DisplayName(), p.OldURN.Name())
}
for _, dep := range p.RemovedDependencies {
switch dep.Type {
case resource.ResourceParent:
fmt.Printf(" removed parent: %s → %s\n", dep.URN.Type().DisplayName(), dep.URN.Name())
case resource.ResourceDependency:
fmt.Printf(" removed dependency: %s → %s\n", dep.URN.Type().DisplayName(), dep.URN.Name())
case resource.ResourcePropertyDependency:
fmt.Printf(" removed property %q dependency: %s → %s\n", dep.Key, dep.URN.Type().DisplayName(), dep.URN.Name())
case resource.ResourceDeletedWith:
fmt.Printf(" removed deletedWith: %s → %s\n", dep.URN.Type().DisplayName(), dep.URN.Name())
case resource.ResourceReplaceWith:
fmt.Printf(" removed replaceWith: %s → %s\n", dep.URN.Type().DisplayName(), dep.URN.Name())
}
}
}

func promptConfirm() error {
fmt.Println()
fmt.Print("Do you want to commit these changes? (Y/n): ")
var response string
_, err := fmt.Scanln(&response)
Expand Down
4 changes: 1 addition & 3 deletions pkg/state/decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ import (

func Decrypt(ctx context.Context, passphrase string, checkpoint *apitype.CheckpointV3) (*apitype.CheckpointV3, error) {
os.Setenv("PULUMI_CONFIG_PASSPHRASE", passphrase)
sp := &defaultSecretsProvider{
passphrase: passphrase,
}
sp := &defaultSecretsProvider{passphrase: passphrase}
snapshot, err := stack.DeserializeCheckpoint(ctx, sp, checkpoint)
if err != nil {
return nil, err
Expand Down
89 changes: 70 additions & 19 deletions pkg/state/state.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,85 @@
package state

import (
"context"
"fmt"
"os"
"slices"

"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/pkg/v3/resource/stack"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
)

// RepairResult describes the changes performed by Repair. Pruned holds
// per-resource changes applied by Snapshot.Prune (URN rewrites + removed
// dangling references). Toposort runs unconditionally and is not reported.
type RepairResult struct {
Pruned []deploy.PruneResult
}

func (r RepairResult) IsEmpty() bool { return len(r.Pruned) == 0 }

// Repair fixes structural integrity issues in the given checkpoint by
// topologically sorting resources and pruning dangling references (Provider,
// Parent, Dependencies, PropertyDependencies, DeletedWith, ReplaceWith).
// Resources whose Parent URN no longer exists are unparented (URN rewritten),
// not deleted. The checkpoint is mutated in place.
func Repair(ctx context.Context, passphrase string, checkpoint *apitype.CheckpointV3) (RepairResult, error) {
os.Setenv("PULUMI_CONFIG_PASSPHRASE", passphrase)
snap, err := stack.DeserializeCheckpoint(ctx, &defaultSecretsProvider{passphrase: passphrase}, checkpoint)
if err != nil {
return RepairResult{}, fmt.Errorf("deserialize checkpoint: %w", err)
}

if err := snap.Toposort(); err != nil {
return RepairResult{}, fmt.Errorf("toposort: %w", err)
}
result := RepairResult{Pruned: snap.Prune()}

if result.IsEmpty() {
return result, nil
}

depl, err := stack.SerializeDeployment(ctx, snap, false)
if err != nil {
return RepairResult{}, fmt.Errorf("serialize deployment: %w", err)
}
checkpoint.Latest = depl
return result, nil
}

// Mutation records a single change made to the state.
type Mutation struct {
Remove *MutationRemove
RemoveDependency *MutationRemoveDependency
RemoveProperty *MutationRemoveProperty
}

// MutationRemove records the removal of a resource.
type MutationRemove struct {
Resource resource.URN
Index int
}

// MutationRemoveDependency records the removal of a dependency reference.
type MutationRemoveDependency struct {
Resource resource.URN
Dependency resource.URN
}

// MutationRemoveProperty records the removal of a property dependency reference.
type MutationRemoveProperty struct {
Resource resource.URN
Dependency resource.URN
Property resource.PropertyKey
}

// Remove deletes every resource in the snapshot whose URN.Name() == target,
// then removes any orphaned children (resources whose parent was deleted) and
// prunes dangling dependency and property-dependency references. The
// checkpoint is mutated in place.
func Remove(target string, checkpoint *apitype.CheckpointV3) []Mutation {
result := []Mutation{}
for resourceIndex := len(checkpoint.Latest.Resources) - 1; resourceIndex >= 0; resourceIndex-- {
Expand All @@ -42,20 +93,17 @@ func Remove(target string, checkpoint *apitype.CheckpointV3) []Mutation {
})
}
}
muts := Repair(checkpoint)
return append(result, muts...)
}

func Repair(checkpoint *apitype.CheckpointV3) []Mutation {
result := []Mutation{}
// Clean up orphaned children and dangling references.
resources := map[resource.URN]bool{}
for _, item := range checkpoint.Latest.Resources {
resources[item.URN] = true
}
var repairs []Mutation
for _, resource := range checkpoint.Latest.Resources {
if resource.Parent != "" {
if _, ok := resources[resource.Parent]; !ok {
result = append(result, Mutation{
repairs = append(repairs, Mutation{
Remove: &MutationRemove{
Resource: resource.URN,
},
Expand All @@ -66,7 +114,7 @@ func Repair(checkpoint *apitype.CheckpointV3) []Mutation {
}
for _, dependency := range resource.Dependencies {
if _, ok := resources[dependency]; !ok {
result = append(result, Mutation{
repairs = append(repairs, Mutation{
RemoveDependency: &MutationRemoveDependency{
Resource: resource.URN,
Dependency: dependency,
Expand All @@ -77,7 +125,7 @@ func Repair(checkpoint *apitype.CheckpointV3) []Mutation {
for key, dependencies := range resource.PropertyDependencies {
for _, dependency := range dependencies {
if _, ok := resources[dependency]; !ok {
result = append(result, Mutation{
repairs = append(repairs, Mutation{
RemoveProperty: &MutationRemoveProperty{
Resource: resource.URN,
Dependency: dependency,
Expand All @@ -89,31 +137,34 @@ func Repair(checkpoint *apitype.CheckpointV3) []Mutation {
}
}

for _, mut := range result {
for _, mut := range repairs {
if mut.Remove != nil {
checkpoint.Latest.Resources = slices.DeleteFunc(checkpoint.Latest.Resources, func(item apitype.ResourceV3) bool {
return item.URN == mut.Remove.Resource
})
}

if mut.RemoveDependency != nil {
index := slices.IndexFunc(checkpoint.Latest.Resources, func(item apitype.ResourceV3) bool {
return item.URN == mut.RemoveDependency.Resource
})
checkpoint.Latest.Resources[index].Dependencies = slices.DeleteFunc(checkpoint.Latest.Resources[index].Dependencies, func(item resource.URN) bool {
return item == mut.RemoveDependency.Dependency
})
if index >= 0 {
checkpoint.Latest.Resources[index].Dependencies = slices.DeleteFunc(checkpoint.Latest.Resources[index].Dependencies, func(item resource.URN) bool {
return item == mut.RemoveDependency.Dependency
})
}
}

if mut.RemoveProperty != nil {
index := slices.IndexFunc(checkpoint.Latest.Resources, func(item apitype.ResourceV3) bool {
return item.URN == mut.RemoveProperty.Resource
})
properties := checkpoint.Latest.Resources[index].PropertyDependencies[mut.RemoveProperty.Property]
checkpoint.Latest.Resources[index].PropertyDependencies[mut.RemoveProperty.Property] = slices.DeleteFunc(properties, func(item resource.URN) bool {
return item == mut.RemoveProperty.Dependency
})
if index >= 0 {
properties := checkpoint.Latest.Resources[index].PropertyDependencies[mut.RemoveProperty.Property]
checkpoint.Latest.Resources[index].PropertyDependencies[mut.RemoveProperty.Property] = slices.DeleteFunc(properties, func(item resource.URN) bool {
return item == mut.RemoveProperty.Dependency
})
}
}
}
return result

return append(result, repairs...)
}
Loading