From 8903cf1f34656516aaa57606757158e3c3b2a3cc Mon Sep 17 00:00:00 2001 From: mkilp <8791079+mkilp@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:52:23 -0400 Subject: [PATCH] feat(graph): add command to generate resource dependency graph --- cmd/sst/state.go | 75 ++++++++++++++++++++++++++++++++++++++++++++ pkg/project/run.go | 19 ++++++----- pkg/project/stack.go | 1 + 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/cmd/sst/state.go b/cmd/sst/state.go index e45909c03e..d86d6aa176 100644 --- a/cmd/sst/state.go +++ b/cmd/sst/state.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "time" @@ -12,6 +13,7 @@ import ( "github.com/sst/sst/v3/internal/util" "github.com/sst/sst/v3/pkg/id" "github.com/sst/sst/v3/pkg/process" + "github.com/sst/sst/v3/pkg/project" "github.com/sst/sst/v3/pkg/project/provider" "github.com/sst/sst/v3/pkg/state" ) @@ -149,6 +151,79 @@ var CmdState = &cli.Command{ return encoder.Encode(exported) }, }, + { + Name: "graph", + Flags: []cli.Flag{ + { + Name: "file", + Type: "string", + Description: cli.Description{ + Short: "Path to write the graph file", + Long: "Path to write the output DOT file. Accepts an absolute path or a path relative to the current working directory. Defaults to `-.dot` in the project root.", + }, + }, + }, + Description: cli.Description{ + Short: "Generate a graph of your app's resource dependencies", + Long: strings.Join([]string{ + "Generates a dependency graph of your app's deployed resources.", + "", + "This exports the resource dependency graph from your most recent deployment", + "as a [DOT format](https://graphviz.org/doc/info/lang.html) file, which can", + "be visualized with tools like [Graphviz](https://graphviz.org/).", + "", + "By default the file is written to `-.dot` in your project root", + "(the directory containing `sst.config.ts`).", + "", + "```bash frame=\"none\"", + "sst state graph", + "```", + "", + "You can specify a custom output path with `--file`.", + "", + "```bash frame=\"none\"", + "sst state graph --file ./my-graph.dot", + "```", + "", + "You can also run this for a specific stage.", + "", + "```bash frame=\"none\"", + "sst state graph --stage production", + "```", + "", + "By default, it runs on your personal stage.", + }, "\n"), + }, + Run: func(c *cli.Cli) error { + p, err := c.InitProject() + if err != nil { + return err + } + defer p.Cleanup() + + outputFile := c.String("file") + if outputFile == "" { + outputFile = filepath.Join(p.PathRoot(), p.App().Name+"-"+p.App().Stage+".dot") + } else if !filepath.IsAbs(outputFile) { + cwd, err := os.Getwd() + if err != nil { + return util.NewReadableError(err, "Could not determine working directory") + } + outputFile = filepath.Join(cwd, outputFile) + } + + err = p.Run(c.Context, &project.StackInput{ + Command: "graph", + OutputFile: outputFile, + }) + if err != nil { + return util.NewReadableError(err, "Could not generate graph") + } + + ui.Success("Graph written to " + outputFile) + return nil + }, + }, { Name: "list", Description: cli.Description{ diff --git a/pkg/project/run.go b/pkg/project/run.go index 72dc279834..020ddaddf5 100644 --- a/pkg/project/run.go +++ b/pkg/project/run.go @@ -60,7 +60,7 @@ func (p *Project) RunNext(ctx context.Context, input *StackInput) error { ID: id.Descending(), } var err error - if input.Command != "diff" { + if input.Command != "diff" && input.Command != "graph" { update, err = p.Lock(input.Command) if err != nil { if err == provider.ErrLockExists { @@ -292,7 +292,10 @@ func (p *Project) RunNext(ctx context.Context, input *StackInput) error { args := []string{ "--stack", fmt.Sprintf("organization/%v/%v", p.app.Name, p.app.Stage), "--non-interactive", - "--event-log", eventlogPath, + } + // Graph command does not support event log + if input.Command != "graph" { + args = append(args, "--event-log", eventlogPath) } if input.Command == "deploy" { @@ -338,9 +341,11 @@ func (p *Project) RunNext(ctx context.Context, input *StackInput) error { args = append([]string{"up", "--yes", "-f"}, args...) case "remove": args = append([]string{"destroy", "--yes", "-f"}, args...) + case "graph": + args = append([]string{"stack", "graph", input.OutputFile}, args...) } - if (input.Command == "diff" || input.Command == "deploy") && input.PolicyPath != "" { + if (input.Command == "diff" || input.Command == "deploy" || input.Command == "graph") && input.PolicyPath != "" { policyPath, err := p.ResolvePolicyPackPath(input.PolicyPath) if err != nil { return util.NewReadableError(nil, err.Error()) @@ -398,7 +403,7 @@ func (p *Project) RunNext(ctx context.Context, input *StackInput) error { defer partialCancel() partialDone := make(chan error) go func() { - if input.Command == "diff" { + if input.Command == "diff" || input.Command == "graph" { return } for { @@ -579,7 +584,7 @@ loop: } } - if input.Command != "diff" && (event.ResOutputsEvent != nil || event.CancelEvent != nil || event.SummaryEvent != nil) { + if input.Command != "diff" && input.Command != "graph" && (event.ResOutputsEvent != nil || event.CancelEvent != nil || event.SummaryEvent != nil) { partial <- 1 } @@ -645,7 +650,7 @@ loop: types.Generate(p.PathConfig(), complete.Links) defer bus.Publish(complete) - if input.Command != "diff" { + if input.Command != "diff" && input.Command != "graph" { log.Info("canceling partial") partialCancel() log.Info("waiting for partial to exit") @@ -662,7 +667,7 @@ loop: defer outputsFile.Close() json.NewEncoder(outputsFile).Encode(complete.Outputs) - if input.Command != "diff " { + if input.Command != "diff " && input.Command != "graph" { update.TimeCompleted = time.Now().Format(time.RFC3339) for _, err := range errors { update.Errors = append(update.Errors, provider.SummaryError{ diff --git a/pkg/project/stack.go b/pkg/project/stack.go index 0dd0100548..96786e9026 100644 --- a/pkg/project/stack.go +++ b/pkg/project/stack.go @@ -25,6 +25,7 @@ type StackInput struct { Continue bool SkipHash string PolicyPath string + OutputFile string } type ConcurrentUpdateEvent struct{}