From dffde2b1b2d05f84b1a70e9d3882a34820c523d2 Mon Sep 17 00:00:00 2001 From: tomasmik Date: Fri, 19 Jan 2024 15:36:21 +0200 Subject: [PATCH 1/2] Allow to get run changes and replan a run --- internal/cmd/stack/flags.go | 11 +++ internal/cmd/stack/run_changes.go | 59 ++++++++++++ internal/cmd/stack/run_replan.go | 143 ++++++++++++++++++++++++++++++ internal/cmd/stack/stack.go | 27 ++++++ 4 files changed, 240 insertions(+) create mode 100644 internal/cmd/stack/run_changes.go create mode 100644 internal/cmd/stack/run_replan.go diff --git a/internal/cmd/stack/flags.go b/internal/cmd/stack/flags.go index 25f92d4..b2b5439 100644 --- a/internal/cmd/stack/flags.go +++ b/internal/cmd/stack/flags.go @@ -141,3 +141,14 @@ var flagDisregardGitignore = &cli.BoolFlag{ Name: "disregard-gitignore", Usage: "[Optional] Disregard the .gitignore file when reading files in a directory", } + +var flagResources = &cli.StringSliceFlag{ + Name: "resources", + Usage: "[Optional] A comma separeted list of resources to be used when applying, example: 'aws_instance.foo'", +} + +var flagInteractive = &cli.BoolFlag{ + Name: "interactive", + Aliases: []string{"i"}, + Usage: "[Optional] Whether to run the command in interactive mode", +} diff --git a/internal/cmd/stack/run_changes.go b/internal/cmd/stack/run_changes.go new file mode 100644 index 0000000..8cdda2c --- /dev/null +++ b/internal/cmd/stack/run_changes.go @@ -0,0 +1,59 @@ +package stack + +import ( + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "github.com/urfave/cli/v2" + + "github.com/spacelift-io/spacectl/internal/cmd" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" +) + +func runChanges(cliCtx *cli.Context) error { + stackID, err := getStackID(cliCtx) + if err != nil { + return err + } + run := cliCtx.String(flagRequiredRun.Name) + + result, err := getRunChanges(cliCtx, stackID, run) + if err != nil { + return err + } + + return cmd.OutputJSON(result) +} + +func getRunChanges(cliCtx *cli.Context, stackID, runID string) ([]runChangesData, error) { + var query struct { + Stack struct { + Run struct { + ChangesV3 []runChangesData `graphql:"changesV3(input: {})"` + } `graphql:"run(id: $run)"` + } `graphql:"stack(id: $stack)"` + } + + variables := map[string]any{ + "stack": graphql.ID(stackID), + "run": graphql.ID(runID), + } + if err := authenticated.Client.Query(cliCtx.Context, &query, variables); err != nil { + return nil, errors.Wrap(err, "failed to query one stack") + } + + return query.Stack.Run.ChangesV3, nil +} + +type runChangesData struct { + Resources []runChangesResource `graphql:"resources"` +} + +type runChangesResource struct { + Address string `graphql:"address"` + PreviousAddress string `graphql:"previousAddress"` + Metadata runChangesMetadata `graphql:"metadata"` +} + +type runChangesMetadata struct { + Type string `graphql:"type"` +} diff --git a/internal/cmd/stack/run_replan.go b/internal/cmd/stack/run_replan.go new file mode 100644 index 0000000..f677eeb --- /dev/null +++ b/internal/cmd/stack/run_replan.go @@ -0,0 +1,143 @@ +package stack + +import ( + "fmt" + "strings" + + "github.com/manifoldco/promptui" + "github.com/shurcooL/graphql" + "github.com/urfave/cli/v2" + + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" +) + +const rocketEmoji = "\U0001F680" + +func runReplan(cliCtx *cli.Context) error { + stackID, err := getStackID(cliCtx) + if err != nil { + return err + } + + runID := cliCtx.String(flagRequiredRun.Name) + + var resources []string + if cliCtx.Bool(flagInteractive.Name) { + var err error + resources, err = interactiveResourceSelection(cliCtx, stackID, runID) + if err != nil { + return err + } + } else { + resources = cliCtx.StringSlice(flagResources.Name) + } + + if len(resources) == 0 { + return fmt.Errorf("no resources targeted for replanning: at least one resource must be specified") + } + + var mutation struct { + RunTargetedReplan struct { + ID string `graphql:"id"` + } `graphql:"runTargetedReplan(stack: $stack, run: $run, targets: $targets)"` + } + + targets := make([]graphql.String, len(resources)) + for i, resource := range resources { + targets[i] = graphql.String(resource) + } + + variables := map[string]interface{}{ + "stack": graphql.ID(stackID), + "run": graphql.ID(runID), + "targets": targets, + } + + if err := authenticated.Client.Mutate(cliCtx.Context, &mutation, variables); err != nil { + return err + } + + fmt.Printf("Run ID %q is being replanned\n", runID) + fmt.Println("The live run can be visited at", authenticated.Client.URL( + "/stack/%s/run/%s", + stackID, + mutation.RunTargetedReplan.ID, + )) + + if !cliCtx.Bool(flagTail.Name) { + return nil + } + + terminal, err := runLogsWithAction(cliCtx.Context, stackID, mutation.RunTargetedReplan.ID, nil) + if err != nil { + return err + } + + return terminal.Error() +} + +func interactiveResourceSelection(cliCtx *cli.Context, stackID, runID string) ([]string, error) { + resources, err := getRunChanges(cliCtx, stackID, runID) + if err != nil { + return nil, err + } + + templates := &promptui.SelectTemplates{ + Label: "{{ . }}?", + Active: fmt.Sprintf("%s {{ .Address | cyan }} %s", rocketEmoji, rocketEmoji), + Inactive: " {{ .Address | cyan }}", + Selected: "\U0001F680 {{ .Address cyan }} \U0001F680", + Details: ` +----------- Details ------------ +{{ "Address:" | faint }} {{ .Address }} +{{ "PreviousAddress:" | faint }} {{ .PreviousAddress }} +{{ "Type:" | faint }} {{ .Metadata.Type }} +`, + } + + values := make([]runChangesResource, 0) + selected := make([]string, 0) + + for _, r := range resources { + values = append(values, r.Resources...) + } + + for { + prompt := promptui.Select{ + Label: "Which resource should be added to the replan", + Items: values, + Templates: templates, + Size: 20, + StartInSearchMode: len(values) > 10, + Searcher: func(input string, index int) bool { + return strings.Contains(values[index].Address, input) + }, + } + + index, _, err := prompt.Run() + if err != nil { + return nil, err + } + + selected = append(selected, values[index].Address) + values = append(values[:index], values[index+1:]...) + + if !shouldPickMore() || len(values) == 0 { + break + } + } + + return selected, nil +} + +func shouldPickMore() bool { + prompt := promptui.Prompt{ + Label: "Pick more", + IsConfirm: true, + Default: "y", + } + + result, _ := prompt.Run() + + return result == "y" || result == "" +} diff --git a/internal/cmd/stack/stack.go b/internal/cmd/stack/stack.go index 6ea12b6..d4c76fe 100644 --- a/internal/cmd/stack/stack.go +++ b/internal/cmd/stack/stack.go @@ -107,6 +107,33 @@ func Command() *cli.Command { Before: authenticated.Ensure, ArgsUsage: cmd.EmptyArgsUsage, }, + { + Category: "Run management", + Name: "replan", + Usage: "Replan an unconfirmed tracked run", + Flags: []cli.Flag{ + flagStackID, + flagRequiredRun, + flagTail, + flagResources, + flagInteractive, + }, + Action: runReplan, + Before: authenticated.Ensure, + ArgsUsage: cmd.EmptyArgsUsage, + }, + { + Category: "Run management", + Name: "changes", + Usage: "Show a list of changes for a given run", + Flags: []cli.Flag{ + flagStackID, + flagRequiredRun, + }, + Action: runChanges, + Before: authenticated.Ensure, + ArgsUsage: cmd.EmptyArgsUsage, + }, { Name: "list", Usage: "List the stacks you have access to", From 005dd33554414d4bc7c0782165f6e79a6dce0f06 Mon Sep 17 00:00:00 2001 From: tomasmik Date: Tue, 23 Jan 2024 14:55:02 +0200 Subject: [PATCH 2/2] Fix rocket emoji for good --- internal/cmd/stack/run_replan.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/stack/run_replan.go b/internal/cmd/stack/run_replan.go index f677eeb..5b7d550 100644 --- a/internal/cmd/stack/run_replan.go +++ b/internal/cmd/stack/run_replan.go @@ -86,7 +86,7 @@ func interactiveResourceSelection(cliCtx *cli.Context, stackID, runID string) ([ Label: "{{ . }}?", Active: fmt.Sprintf("%s {{ .Address | cyan }} %s", rocketEmoji, rocketEmoji), Inactive: " {{ .Address | cyan }}", - Selected: "\U0001F680 {{ .Address cyan }} \U0001F680", + Selected: fmt.Sprintf("%s {{ .Address cyan }} %s", rocketEmoji, rocketEmoji), Details: ` ----------- Details ------------ {{ "Address:" | faint }} {{ .Address }}