diff --git a/cmd/sst/mosaic/ui/ui.go b/cmd/sst/mosaic/ui/ui.go index 11d8d8158f..12cb52cd0c 100644 --- a/cmd/sst/mosaic/ui/ui.go +++ b/cmd/sst/mosaic/ui/ui.go @@ -505,6 +505,15 @@ func (u *UI) Event(unknown interface{}) { } } u.blank() + case *project.HookStartEvent: + u.printEvent(TEXT_INFO, "Hook", fmt.Sprintf("Running %s hook...", evt.Hook)) + + case *project.HookCompleteEvent: + u.printEvent(TEXT_SUCCESS, "Hook", fmt.Sprintf("%s completed", evt.Hook)) + + case *project.HookErrorEvent: + u.printEvent(TEXT_DANGER, "Hook", fmt.Sprintf("%s failed: %s", evt.Hook, evt.Error)) + case *cloudflare.WorkerBuildEvent: if len(evt.Errors) > 0 { u.printEvent(TEXT_DANGER, "Build Error", u.functionName(evt.WorkerID)+" "+strings.Join(evt.Errors, "\n")) diff --git a/pkg/flag/flag.go b/pkg/flag/flag.go index 36a6a53a07..04bdd5f1c7 100644 --- a/pkg/flag/flag.go +++ b/pkg/flag/flag.go @@ -23,6 +23,7 @@ var SST_EXPERIMENTAL = isTrue("SST_EXPERIMENTAL") || isTrue("SST_EXPERIMENTAL_RU var SST_RUN_ID = os.Getenv("SST_RUN_ID") var SST_SKIP_APPSYNC = isTrue("SST_SKIP_APPSYNC") var SST_NO_BUN = isTrue("NO_BUN") || isTrue("SST_NO_BUN") +var SST_HOOK_TIMEOUT = os.Getenv("SST_HOOK_TIMEOUT") func isTrue(name string) bool { val, ok := os.LookupEnv(name) diff --git a/pkg/project/hooks.go b/pkg/project/hooks.go new file mode 100644 index 0000000000..2ea842a244 --- /dev/null +++ b/pkg/project/hooks.go @@ -0,0 +1,152 @@ +package project + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/sst/sst/v3/pkg/bus" + "github.com/sst/sst/v3/pkg/flag" + "github.com/sst/sst/v3/pkg/js" + "github.com/sst/sst/v3/pkg/process" +) + +const HookDeployAfter = "deploy.after" + +type DeployHookResult struct { + Success bool `json:"success"` + Errors []DeployHookError `json:"errors"` + Outputs map[string]interface{} `json:"outputs"` + Resources int `json:"resources"` + App DeployHookApp `json:"app"` +} + +type DeployHookError struct { + Message string `json:"message"` + URN string `json:"urn,omitempty"` + Help []string `json:"help,omitempty"` +} + +type DeployHookApp struct { + Name string `json:"name"` + Stage string `json:"stage"` +} + +func (p *Project) RunDeployHook(complete *CompleteEvent) error { + log := slog.Default().With("service", "project.hooks") + + if complete == nil { + log.Warn("skipping deploy.after hook: complete event is nil") + return nil + } + + log.Info("running deploy.after hook") + + // Set up timeout context + timeout := 30 * time.Second + if flag.SST_HOOK_TIMEOUT != "" { + seconds, err := strconv.Atoi(flag.SST_HOOK_TIMEOUT) + if err != nil { + log.Warn("invalid SST_HOOK_TIMEOUT value, using default", + "value", flag.SST_HOOK_TIMEOUT, + "default", timeout, + "err", err) + } else if seconds <= 0 { + log.Warn("SST_HOOK_TIMEOUT must be positive, using default", + "value", seconds, + "default", timeout) + } else { + timeout = time.Duration(seconds) * time.Second + } + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + bus.Publish(&HookStartEvent{Hook: HookDeployAfter}) + + // Build the hook result from CompleteEvent + hookErrors := make([]DeployHookError, len(complete.Errors)) + for i, err := range complete.Errors { + hookErrors[i] = DeployHookError{ + Message: err.Message, + URN: err.URN, + Help: err.Help, + } + } + + result := DeployHookResult{ + Success: len(complete.Errors) == 0, + Errors: hookErrors, + Outputs: complete.Outputs, + Resources: len(complete.Resources), + App: DeployHookApp{ + Name: p.app.Name, + Stage: p.app.Stage, + }, + } + + resultBytes, err := json.Marshal(result) + if err != nil { + bus.Publish(&HookErrorEvent{Hook: HookDeployAfter, Error: err.Error()}) + return fmt.Errorf("failed to marshal hook result: %w", err) + } + + outfile := filepath.Join(p.PathPlatformDir(), fmt.Sprintf("hook.deploy.%v.mjs", time.Now().UnixMilli())) + + buildResult, err := js.Build(js.EvalOptions{ + Dir: p.PathRoot(), + Outfile: outfile, + Define: map[string]string{ + "$hookResult": string(resultBytes), + }, + Code: fmt.Sprintf(` +import mod from '%s'; +const result = $hookResult; +if (mod.hooks?.deploy?.after) { + await mod.hooks.deploy.after(result); +} +`, + filepath.ToSlash(p.PathConfig()), + ), + }) + if err != nil { + bus.Publish(&HookErrorEvent{Hook: HookDeployAfter, Error: err.Error()}) + return fmt.Errorf("failed to build hook: %w", err) + } + if !flag.SST_NO_CLEANUP { + defer js.Cleanup(buildResult) + } + + log.Info("executing deploy.after hook", "outfile", outfile, "timeout", timeout) + node := process.CommandContext(ctx, "node", "--no-warnings", outfile) + node.Env = os.Environ() + output, err := node.CombinedOutput() + if err != nil { + var errMsg string + switch ctx.Err() { + case context.Canceled: + errMsg = fmt.Sprintf("hook was canceled\n%s", string(output)) + case context.DeadlineExceeded: + errMsg = fmt.Sprintf("hook timed out after %v\n%s", timeout, string(output)) + default: + errMsg = fmt.Sprintf("hook execution failed: %s\n%s", err.Error(), string(output)) + } + bus.Publish(&HookErrorEvent{Hook: HookDeployAfter, Error: errMsg}) + log.Error("deploy.after hook failed", "err", err, "output", string(output)) + return fmt.Errorf("hook %s failed: %s", HookDeployAfter, errMsg) + } + + // Log any output from the hook + if len(output) > 0 { + log.Info("deploy.after hook output", "output", string(output)) + } + + bus.Publish(&HookCompleteEvent{Hook: HookDeployAfter}) + log.Info("deploy.after hook completed") + return nil +} diff --git a/pkg/project/run.go b/pkg/project/run.go index a9ff577643..c51a3d6cde 100644 --- a/pkg/project/run.go +++ b/pkg/project/run.go @@ -573,6 +573,13 @@ loop: provider.Cleanup(p.home, p.app.Name, p.app.Stage) } + // Run deploy.after hook for deploy command only (not dev or diff) + if input.Command == "deploy" && !input.Dev && complete.Finished { + if err := p.RunDeployHook(complete); err != nil { + return err + } + } + log.Info("done running stack command", "resources", len(complete.Resources)) if cmd.ProcessState.ExitCode() > 0 { return ErrStackRunFailed diff --git a/pkg/project/stack.go b/pkg/project/stack.go index d9e5d47189..722912784e 100644 --- a/pkg/project/stack.go +++ b/pkg/project/stack.go @@ -93,6 +93,19 @@ type StackCommandEvent struct { Version string } +type HookStartEvent struct { + Hook string +} + +type HookCompleteEvent struct { + Hook string +} + +type HookErrorEvent struct { + Hook string + Error string +} + type Error struct { Message string `json:"message"` URN string `json:"urn"` diff --git a/platform/src/config.ts b/platform/src/config.ts index c8297e4326..2695688ea9 100644 --- a/platform/src/config.ts +++ b/platform/src/config.ts @@ -833,6 +833,81 @@ export interface WorkflowInput { event: BranchEvent | PullRequestEvent | TagEvent | UserEvent; } +/** + * An error that occurred during deployment. + */ +export interface DeployError { + /** + * The error message. + */ + message: string; + /** + * The URN of the resource that caused the error, if applicable. + */ + urn?: string; + /** + * Additional help text for resolving the error. + */ + help?: string[]; +} + +/** + * The result passed to the `deploy.after` hook. + */ +export interface DeployResult { + /** + * Whether the deployment completed without errors. + */ + success: boolean; + /** + * Array of errors that occurred during deployment. Empty if `success` is true. + */ + errors: DeployError[]; + /** + * The outputs from your `run` function. + */ + outputs: Record; + /** + * Number of resources in the stack. + */ + resources: number; + /** + * Information about the app. + */ + app: { + /** + * The app name. + */ + name: string; + /** + * The stage name. + */ + stage: string; + }; +} + +/** + * Hooks for the deploy operation. + */ +export interface DeployHooks { + /** + * Runs after a deployment completes (success or failure). + * + * @param result - The deployment result + */ + after?(result: DeployResult): Promise | void; +} + +/** + * Hooks that run at specific points during operations. + */ +export interface Hooks { + /** + * Hooks for the deploy operation. + */ + deploy?: DeployHooks; +} + export interface Config { /** * The config for your app. It needs to return an object of type [`App`](#app-1). The `app` @@ -1270,6 +1345,64 @@ export interface Config { * ``` */ run(): Promise | void>; + /** + * Configure hooks that run at specific points during operations. + * + * Currently supports a `deploy.after` hook that runs after a deployment completes. + * This is useful for sending notifications (like Slack), triggering follow-up actions, + * or logging deployment results. + * + * :::note + * The hook runs after `sst deploy` completes, not during `sst dev` or `sst diff`. + * ::: + * + * :::caution + * The hook will not run if the deployment is interrupted (e.g., Ctrl+C). + * ::: + * + * :::caution + * Hook errors will cause the deployment to fail. If you want to handle errors + * gracefully and prevent deployment failure, wrap your hook logic in a try-catch block. + * ::: + * + * @example + * + * ```ts title="sst.config.ts" + * hooks: { + * deploy: { + * async after(result) { + * // Send a notification to your webhook + * await fetch("https://example.com/webhook", { + * method: "POST", + * body: JSON.stringify({ + * text: `Deployed ${result.app.name} to ${result.app.stage}` + * }) + * }); + * } + * } + * } + * ``` + * + * The `after` hook receives a `DeployResult` object containing: + * - `success` — Whether the deployment completed without errors + * - `errors` — Array of errors if any occurred + * - `outputs` — The outputs from your `run` function + * - `resources` — Number of resources in the stack + * - `app.name` — The app name + * - `app.stage` — The stage name + * + * #### Timeout + * + * By default, the hook has a 30-second timeout. You can customize this by setting + * the `SST_HOOK_TIMEOUT` environment variable to the number of seconds. + * + * ```bash + * SST_HOOK_TIMEOUT=60 sst deploy + * ``` + * + * This sets the timeout to 60 seconds. + */ + hooks?: Hooks; } /** @internal */