From fae442596a8fc39e881d030f0ef28b0f5df16e1b Mon Sep 17 00:00:00 2001 From: Finn Arne Gangstad Date: Tue, 8 Oct 2024 17:31:39 +0200 Subject: [PATCH] Add new draftplan command. draftplan acts like a normal plan command, except it does not grab a project lock or a terraform lock, and does not refresh state. The resulting plan will only be printed as a comment, it will not be stored and so cannot be applied. It is intended a a lightweight method to see what a plan would look like. --- cmd/server.go | 2 +- .../events/events_controller_e2e_test.go | 1 + server/core/runtime/plan_step_runner.go | 51 +++++++++++-------- server/core/runtime/plan_step_runner_test.go | 1 + server/events/command/name.go | 7 +++ server/events/command/project_result.go | 2 +- server/events/command_runner_internal_test.go | 2 +- server/events/command_runner_test.go | 1 + server/events/comment_parser.go | 16 ++++++ server/events/comment_parser_test.go | 47 ++++++++++++++++- server/events/commit_status_updater.go | 2 + .../instrumented_project_command_builder.go | 2 +- server/events/markdown_renderer.go | 24 +++++++-- server/events/plan_command_runner.go | 42 ++++++++------- server/events/project_command_builder.go | 2 +- .../events/project_command_context_builder.go | 2 +- server/events/project_command_runner.go | 9 ++-- server/events/project_command_runner_test.go | 2 + .../draftplan_success_unwrapped.tmpl | 7 +++ .../templates/draftplan_success_wrapped.tmpl | 11 ++++ .../templates/multi_project_draftplan.tmpl | 12 +++++ .../single_project_draftplan_success.tmpl | 7 +++ ...single_project_draftplan_unsuccessful.tmpl | 7 +++ server/server.go | 1 + server/user_config_test.go | 4 +- 25 files changed, 208 insertions(+), 56 deletions(-) create mode 100644 server/events/templates/draftplan_success_unwrapped.tmpl create mode 100644 server/events/templates/draftplan_success_wrapped.tmpl create mode 100644 server/events/templates/multi_project_draftplan.tmpl create mode 100644 server/events/templates/single_project_draftplan_success.tmpl create mode 100644 server/events/templates/single_project_draftplan_unsuccessful.tmpl diff --git a/cmd/server.go b/cmd/server.go index e53ca20418..c9957d9645 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -161,7 +161,7 @@ const ( DefaultADHostname = "dev.azure.com" DefaultAutoDiscoverMode = "auto" DefaultAutoplanFileList = "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl" - DefaultAllowCommands = "version,plan,apply,unlock,approve_policies" + DefaultAllowCommands = "version,draftplan,plan,apply,unlock,approve_policies" DefaultCheckoutStrategy = CheckoutStrategyBranch DefaultCheckoutDepth = 0 DefaultBitbucketBaseURL = bitbucketcloud.BaseURL diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index 68e517709c..c7fb359e17 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -1587,6 +1587,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ command.Plan: planCommandRunner, + command.DraftPlan: planCommandRunner, command.Apply: applyCommandRunner, command.ApprovePolicies: approvePoliciesCommandRunner, command.Unlock: unlockCommandRunner, diff --git a/server/core/runtime/plan_step_runner.go b/server/core/runtime/plan_step_runner.go index b1cb66c1e4..cfd70940fd 100644 --- a/server/core/runtime/plan_step_runner.go +++ b/server/core/runtime/plan_step_runner.go @@ -73,8 +73,12 @@ func (p *planStepRunner) isRemoteOpsErr(output string, err error) bool { // remotePlan runs a terraform plan command compatible with TFE remote // operations. func (p *planStepRunner) remotePlan(ctx command.ProjectContext, extraArgs []string, path string, tfVersion *version.Version, planFile string, envs map[string]string) (string, error) { + baseCommand := []string{"plan", "-input=false", "-refresh", "-no-color"} + if ctx.CommandName == command.DraftPlan { + baseCommand = []string{"plan", "-input=false", "-no-color", "-refresh=false", "-lock=false"} + } argList := [][]string{ - {"plan", "-input=false", "-refresh", "-no-color"}, + baseCommand, extraArgs, ctx.EscapedCommentArgs, } @@ -84,21 +88,23 @@ func (p *planStepRunner) remotePlan(ctx command.ProjectContext, extraArgs []stri return output, err } - // If using remote ops, we create our own "fake" planfile with the - // text output of the plan. We do this for two reasons: - // 1) Atlantis relies on there being a planfile on disk to detect which - // projects have outstanding plans. - // 2) Remote ops don't support the -out parameter so we can't save the - // plan. To ensure that what gets applied is the plan we printed to the PR, - // during the apply phase, we diff the output we stored in the fake - // planfile with the pending apply output. - planOutput := StripRefreshingFromPlanOutput(output, tfVersion) - - // We also prepend our own remote ops header to the file so during apply we - // know this is a remote apply. - err = os.WriteFile(planFile, []byte(remoteOpsHeader+planOutput), 0600) - if err != nil { - return output, errors.Wrap(err, "unable to create planfile for remote ops") + if ctx.CommandName != command.DraftPlan { + // If using remote ops, we create our own "fake" planfile with the + // text output of the plan. We do this for two reasons: + // 1) Atlantis relies on there being a planfile on disk to detect which + // projects have outstanding plans. + // 2) Remote ops don't support the -out parameter so we can't save the + // plan. To ensure that what gets applied is the plan we printed to the PR, + // during the apply phase, we diff the output we stored in the fake + // planfile with the pending apply output. + planOutput := StripRefreshingFromPlanOutput(output, tfVersion) + + // We also prepend our own remote ops header to the file so during apply we + // know this is a remote apply. + err = os.WriteFile(planFile, []byte(remoteOpsHeader+planOutput), 0600) + if err != nil { + return output, errors.Wrap(err, "unable to create planfile for remote ops") + } } return p.fmtPlanOutput(output, tfVersion), nil @@ -117,10 +123,15 @@ func (p *planStepRunner) buildPlanCmd(ctx command.ProjectContext, extraArgs []st envFileArgs = []string{"-var-file", envFile} } + // NOTE: we need to quote the plan filename because Bitbucket Server can + // have spaces in its repo owner names. + baseCommand := []string{"plan", "-input=false", "-refresh", "-out", fmt.Sprintf("%q", planFile)} + if ctx.CommandName == command.DraftPlan { + baseCommand = []string{"plan", "-input=false", "-refresh=false", "-lock=false"} + } + argList := [][]string{ - // NOTE: we need to quote the plan filename because Bitbucket Server can - // have spaces in its repo owner names. - {"plan", "-input=false", "-refresh", "-out", fmt.Sprintf("%q", planFile)}, + baseCommand, tfVars, extraArgs, ctx.EscapedCommentArgs, @@ -198,7 +209,7 @@ func (p *planStepRunner) runRemotePlan( // updateStatusF will update the commit status and log any error. updateStatusF := func(status models.CommitStatus, url string) { - if err := p.CommitStatusUpdater.UpdateProject(ctx, command.Plan, status, url, nil); err != nil { + if err := p.CommitStatusUpdater.UpdateProject(ctx, ctx.CommandName, status, url, nil); err != nil { ctx.Log.Err("unable to update status: %s", err) } } diff --git a/server/core/runtime/plan_step_runner_test.go b/server/core/runtime/plan_step_runner_test.go index f05336637c..5478e0d568 100644 --- a/server/core/runtime/plan_step_runner_test.go +++ b/server/core/runtime/plan_step_runner_test.go @@ -371,6 +371,7 @@ locally at this time. // Now that mocking is set up, we're ready to run the plan. ctx := command.ProjectContext{ Log: logger, + CommandName: command.Plan, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, diff --git a/server/events/command/name.go b/server/events/command/name.go index cfc541ea2b..43f97fce7d 100644 --- a/server/events/command/name.go +++ b/server/events/command/name.go @@ -30,6 +30,8 @@ const ( Import // State is a command to run terraform state rm State + // DraftPlan is a light-weight plan that cannot be applied + DraftPlan // Adding more? Don't forget to update String() below ) @@ -47,6 +49,7 @@ var AllCommentCommands = []Name{ ApprovePolicies, Import, State, + DraftPlan, } // TitleString returns the string representation in title form. @@ -74,6 +77,8 @@ func (c Name) String() string { return "import" case State: return "state" + case DraftPlan: + return "draftplan" } return "" } @@ -137,6 +142,8 @@ func ParseCommandName(name string) (Name, error) { return Apply, nil case "plan": return Plan, nil + case "draftplan": + return DraftPlan, nil case "unlock": return Unlock, nil case "policy_check": diff --git a/server/events/command/project_result.go b/server/events/command/project_result.go index 8f72f1d168..4f67d0c6cf 100644 --- a/server/events/command/project_result.go +++ b/server/events/command/project_result.go @@ -53,7 +53,7 @@ func (p ProjectResult) PolicyStatus() []models.PolicySetStatus { func (p ProjectResult) PlanStatus() models.ProjectPlanStatus { switch p.Command { - case Plan: + case Plan, DraftPlan: if p.Error != nil { return models.ErroredPlanStatus } else if p.Failure != "" { diff --git a/server/events/command_runner_internal_test.go b/server/events/command_runner_internal_test.go index 02a54c3870..d269b87632 100644 --- a/server/events/command_runner_internal_test.go +++ b/server/events/command_runner_internal_test.go @@ -154,7 +154,7 @@ func TestPlanUpdatePlanCommitStatus(t *testing.T) { cr := &PlanCommandRunner{ commitStatusUpdater: csu, } - cr.updateCommitStatus(&command.Context{}, c.pullStatus, command.Plan) + cr.updateCommitStatus(&command.Context{}, c.pullStatus, c.cmd) Equals(t, models.Repo{}, csu.CalledRepo) Equals(t, models.PullRequest{}, csu.CalledPull) Equals(t, c.expStatus, csu.CalledStatus) diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 07026b6837..45c918514f 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -218,6 +218,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ command.Plan: planCommandRunner, + command.DraftPlan: planCommandRunner, command.Apply: applyCommandRunner, command.ApprovePolicies: approvePoliciesCommandRunner, command.Unlock: unlockCommandRunner, diff --git a/server/events/comment_parser.go b/server/events/comment_parser.go index 3b3d2d3b0a..447b775378 100644 --- a/server/events/comment_parser.go +++ b/server/events/comment_parser.go @@ -240,6 +240,14 @@ func (e *CommentParser) Parse(rawComment string, vcsHost models.VCSHostType) Com flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run plan in relative to root of repo, ex. 'child/dir'.") flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Which project to run plan for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") + case command.DraftPlan.String(): + name = command.DraftPlan + flagSet = pflag.NewFlagSet(command.Plan.String(), pflag.ContinueOnError) + flagSet.SetOutput(io.Discard) + flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before planning.") + flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run plan in relative to root of repo, ex. 'child/dir'.") + flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Which project to run plan for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") + flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") case command.Apply.String(): name = command.Apply flagSet = pflag.NewFlagSet(command.Apply.String(), pflag.ContinueOnError) @@ -490,6 +498,7 @@ func (e *CommentParser) HelpComment() string { if err := tmpl.Execute(buf, struct { ExecutableName string AllowVersion bool + AllowDraftPlan bool AllowPlan bool AllowApply bool AllowUnlock bool @@ -499,6 +508,7 @@ func (e *CommentParser) HelpComment() string { }{ ExecutableName: e.ExecutableName, AllowVersion: e.isAllowedCommand(command.Version.String()), + AllowDraftPlan: e.isAllowedCommand(command.DraftPlan.String()), AllowPlan: e.isAllowedCommand(command.Plan.String()), AllowApply: e.isAllowedCommand(command.Apply.String()), AllowUnlock: e.isAllowedCommand(command.Unlock.String()), @@ -540,6 +550,12 @@ Commands: plan Runs 'terraform plan' for the changes in this pull request. To plan a specific project, use the -d, -w and -p flags. {{- end }} +{{- if .AllowDraftPlan }} + draftplan + Runs plan in draft mode. Runs quickly without locks or + refreshing, plan is only added to PR comments. Cannot be applied. + To plan a specific project, use the -d, -w and -p flags. +{{- end }} {{- if .AllowApply }} apply Runs 'terraform apply' on all unapplied plans from this pull request. To only apply a specific plan, use the -d, -w and -p flags. diff --git a/server/events/comment_parser_test.go b/server/events/comment_parser_test.go index 45c22e7e5f..6c730a15d3 100644 --- a/server/events/comment_parser_test.go +++ b/server/events/comment_parser_test.go @@ -176,6 +176,31 @@ func TestParse_UnusedArguments(t *testing.T) { "-d . arg -w kjj arg2", "arg arg2", }, + { + command.DraftPlan, + "-d . arg", + "arg", + }, + { + command.DraftPlan, + "arg -d .", + "arg", + }, + { + command.DraftPlan, + "arg", + "arg", + }, + { + command.DraftPlan, + "arg arg2", + "arg arg2", + }, + { + command.DraftPlan, + "-d . arg -w kjj arg2", + "arg arg2", + }, { command.Apply, "-d . arg", @@ -220,6 +245,8 @@ func TestParse_UnusedArguments(t *testing.T) { switch c.Command { case command.Plan: usage = PlanUsage + case command.DraftPlan: + usage = DraftPlanUsage case command.Apply: usage = ApplyUsage case command.ApprovePolicies: @@ -277,12 +304,13 @@ func TestParse_InvalidCommand(t *testing.T) { command.Unlock, command.Apply, command.Plan, + command.DraftPlan, command.Apply, // duplicate command is filtered }, ) for _, c := range comments { r := cp.Parse(c, models.Github) - exp := fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\nAvailable commands(--allow-commands): version, plan, apply, unlock\n```", strings.Fields(c)[1]) + exp := fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\nAvailable commands(--allow-commands): version, plan, apply, unlock, draftplan\n```", strings.Fields(c)[1]) Equals(t, exp, r.CommentResponse) } } @@ -330,6 +358,10 @@ func TestParse_InvalidFlags(t *testing.T) { "atlantis plan --abc", "Error: unknown flag: --abc", }, + { + "atlantis draftplan --abc", + "Error: unknown flag: --abc", + }, { "atlantis apply -e", "Error: unknown shorthand flag: 'e' in -e", @@ -874,6 +906,10 @@ Examples: Commands: plan Runs 'terraform plan' for the changes in this pull request. To plan a specific project, use the -d, -w and -p flags. + draftplan + Runs plan in draft mode. Runs quickly without locks or + refreshing, plan is only added to PR comments. Cannot be applied. + To plan a specific project, use the -d, -w and -p flags. apply Runs 'terraform apply' on all unapplied plans from this pull request. To only apply a specific plan, use the -d, -w and -p flags. unlock Removes all atlantis locks and discards all plans for this PR. @@ -1018,6 +1054,15 @@ var PlanUsage = `Usage of plan: --verbose Append Atlantis log to comment. -w, --workspace string Switch to this Terraform workspace before planning. ` +var DraftPlanUsage = `Usage of draftplan: + -d, --dir string Which directory to run plan in relative to root of repo, + ex. 'child/dir'. + -p, --project string Which project to run plan for. Refers to the name of the + project configured in a repo config file. Cannot be used + at same time as workspace or dir flags. + --verbose Append Atlantis log to comment. + -w, --workspace string Switch to this Terraform workspace before planning. +` var ApplyUsage = `Usage of apply: --auto-merge-disabled Disable automerge after apply. diff --git a/server/events/commit_status_updater.go b/server/events/commit_status_updater.go index a05b7ef808..599c8edb33 100644 --- a/server/events/commit_status_updater.go +++ b/server/events/commit_status_updater.go @@ -77,6 +77,8 @@ func (d *DefaultCommitStatusUpdater) UpdateCombinedCount(logger logging.SimpleLo cmdVerb = "policies checked" case command.Apply: cmdVerb = "applied" + case command.DraftPlan: + cmdVerb = "draftplanned" } return d.Client.UpdateStatus(logger, repo, pull, status, src, fmt.Sprintf("%d/%d projects %s successfully.", numSuccess, numTotal, cmdVerb), "") diff --git a/server/events/instrumented_project_command_builder.go b/server/events/instrumented_project_command_builder.go index 36ea9361b7..7e48e4d229 100644 --- a/server/events/instrumented_project_command_builder.go +++ b/server/events/instrumented_project_command_builder.go @@ -33,7 +33,7 @@ func (b *InstrumentedProjectCommandBuilder) BuildAutoplanCommands(ctx *command.C func (b *InstrumentedProjectCommandBuilder) BuildPlanCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { return b.buildAndEmitStats( - "plan", + comment.Name.String(), func() ([]command.ProjectContext, error) { return b.ProjectCommandBuilder.BuildPlanCommands(ctx, comment) }, diff --git a/server/events/markdown_renderer.go b/server/events/markdown_renderer.go index 5bbfc8a47e..aedb2e82e5 100644 --- a/server/events/markdown_renderer.go +++ b/server/events/markdown_renderer.go @@ -29,6 +29,7 @@ import ( var ( planCommandTitle = command.Plan.TitleString() + draftplanCommandTitle = command.DraftPlan.TitleString() applyCommandTitle = command.Apply.TitleString() policyCheckCommandTitle = command.PolicyCheck.TitleString() approvePoliciesCommandTitle = command.ApprovePolicies.TitleString() @@ -238,11 +239,20 @@ func (m *MarkdownRenderer) renderProjectResults(ctx *command.Context, results [] EnableDiffMarkdownFormat: common.EnableDiffMarkdownFormat, PlanStats: result.PlanSuccess.Stats(), } - if m.shouldUseWrappedTmpl(vcsHost, result.PlanSuccess.TerraformOutput) { - data.PlanSummary = result.PlanSuccess.Summary() - resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessWrapped"), data) + if common.Command == draftplanCommandTitle { + if m.shouldUseWrappedTmpl(vcsHost, result.PlanSuccess.TerraformOutput) { + data.PlanSummary = result.PlanSuccess.Summary() + resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("draftplanSuccessWrapped"), data) + } else { + resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("draftplanSuccessUnwrapped"), data) + } } else { - resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessUnwrapped"), data) + if m.shouldUseWrappedTmpl(vcsHost, result.PlanSuccess.TerraformOutput) { + data.PlanSummary = result.PlanSuccess.Summary() + resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessWrapped"), data) + } else { + resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessUnwrapped"), data) + } } resultData.NoChanges = result.PlanSuccess.NoChanges() if result.PlanSuccess.NoChanges() { @@ -343,8 +353,12 @@ func (m *MarkdownRenderer) renderProjectResults(ctx *command.Context, results [] switch { case len(resultsTmplData) == 1 && common.Command == planCommandTitle && numPlanSuccesses > 0: tmpl = templates.Lookup("singleProjectPlanSuccess") + case len(resultsTmplData) == 1 && common.Command == draftplanCommandTitle && numPlanSuccesses > 0: + tmpl = templates.Lookup("singleProjectDraftPlanSuccess") case len(resultsTmplData) == 1 && common.Command == planCommandTitle && numPlanSuccesses == 0: tmpl = templates.Lookup("singleProjectPlanUnsuccessful") + case len(resultsTmplData) == 1 && common.Command == draftplanCommandTitle && numPlanSuccesses == 0: + tmpl = templates.Lookup("singleProjectDraftPlanUnsuccessful") case len(resultsTmplData) == 1 && common.Command == policyCheckCommandTitle && numPolicyCheckSuccesses > 0: tmpl = templates.Lookup("singleProjectPlanSuccess") case len(resultsTmplData) == 1 && common.Command == policyCheckCommandTitle && numPolicyCheckSuccesses == 0: @@ -366,6 +380,8 @@ func (m *MarkdownRenderer) renderProjectResults(ctx *command.Context, results [] } case common.Command == planCommandTitle: tmpl = templates.Lookup("multiProjectPlan") + case common.Command == draftplanCommandTitle: + tmpl = templates.Lookup("multiProjectDraftPlan") case common.Command == policyCheckCommandTitle: if numPolicyCheckSuccesses == len(results) { tmpl = templates.Lookup("multiProjectPolicy") diff --git a/server/events/plan_command_runner.go b/server/events/plan_command_runner.go index c2b6b7a107..704c11385b 100644 --- a/server/events/plan_command_runner.go +++ b/server/events/plan_command_runner.go @@ -182,19 +182,19 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) } - if p.DiscardApprovalOnPlan { + if cmd.Name != command.DraftPlan && p.DiscardApprovalOnPlan { if err = p.pullUpdater.VCSClient.DiscardReviews(baseRepo, pull); err != nil { ctx.Log.Err("failed to remove approvals: %s", err) } } - if err = p.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.PendingCommitStatus, command.Plan); err != nil { + if err = p.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.PendingCommitStatus, cmd.Name); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } projectCmds, err := p.prjCmdBuilder.BuildPlanCommands(ctx, cmd) if err != nil { - if statusErr := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); statusErr != nil { + if statusErr := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, cmd.Name); statusErr != nil { ctx.Log.Warn("unable to update commit status: %s", statusErr) } p.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err}) @@ -214,27 +214,29 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { if pullStatus == nil { // default to 0/0 ctx.Log.Debug("setting VCS status to 0/0 success as no previous state was found") - if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { + if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, cmd.Name, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } return } ctx.Log.Debug("resetting VCS status") - p.updateCommitStatus(ctx, *pullStatus, command.Plan) + p.updateCommitStatus(ctx, *pullStatus, cmd.Name) } else { // With a generic plan, we set successful commit statuses // with 0/0 projects planned successfully because some users require // the Atlantis status to be passing for all pull requests. // Does not apply to skipped runs for specific projects ctx.Log.Debug("setting VCS status to success with no projects found") - if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { + if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, cmd.Name, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } - if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { - ctx.Log.Warn("unable to update commit status: %s", err) - } - if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { - ctx.Log.Warn("unable to update commit status: %s", err) + if cmd.Name == command.Plan { + if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } + if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } } } } @@ -245,7 +247,7 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { // if the plan is generic, new plans will be generated based on changes // discard previous plans that might not be relevant anymore - if !cmd.IsForSpecificProject() { + if cmd.Name != command.DraftPlan && !cmd.IsForSpecificProject() { ctx.Log.Debug("deleting previous plans and locks") p.deletePlans(ctx) _, err = p.lockingLocker.UnlockByPull(baseRepo.FullName, pull.Num) @@ -263,7 +265,7 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { result = runProjectCmds(projectCmds, p.prjCmdRunner.Plan) } - if p.autoMerger.automergeEnabled(projectCmds) && result.HasErrors() { + if cmd.Name != command.DraftPlan && p.autoMerger.automergeEnabled(projectCmds) && result.HasErrors() { ctx.Log.Info("deleting plans because there were errors and automerge requires all plans succeed") p.deletePlans(ctx) result.PlansDeleted = true @@ -280,16 +282,18 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { return } - p.updateCommitStatus(ctx, pullStatus, command.Plan) - p.updateCommitStatus(ctx, pullStatus, command.Apply) + p.updateCommitStatus(ctx, pullStatus, cmd.Name) + if cmd.Name == command.Plan { + p.updateCommitStatus(ctx, pullStatus, command.Apply) + } // Runs policy checks step after all plans are successful. // This step does not approve any policies that require approval. - if len(result.ProjectResults) > 0 && + if cmd.Name == command.Plan && len(result.ProjectResults) > 0 && !(result.HasErrors() || result.PlansDeleted) { ctx.Log.Info("Running policy check for %s", cmd.String()) p.policyCheckCommandRunner.Run(ctx, policyCheckCmds) - } else if len(projectCmds) == 0 && !cmd.IsForSpecificProject() { + } else if len(projectCmds) == 0 && cmd.Name == command.Plan && !cmd.IsForSpecificProject() { // If there were no projects modified, we set successful commit statuses // with 0/0 projects planned/policy_checked/applied successfully because some users require // the Atlantis status to be passing for all pull requests. @@ -313,7 +317,7 @@ func (p *PlanCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus var numErrored int status := models.SuccessCommitStatus - if commandName == command.Plan { + if commandName == command.Plan || commandName == command.DraftPlan { numErrored = pullStatus.StatusCount(models.ErroredPlanStatus) // We consider anything that isn't a plan error as a plan success. // For example, if there is an apply error, that means that at least a @@ -368,7 +372,7 @@ func (p *PlanCommandRunner) partitionProjectCmds( ) { for _, cmd := range cmds { switch cmd.CommandName { - case command.Plan: + case command.Plan, command.DraftPlan: projectCmds = append(projectCmds, cmd) case command.PolicyCheck: policyCheckCmds = append(policyCheckCmds, cmd) diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 35e540a858..f86252ebba 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -685,7 +685,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Cont return p.buildProjectCommandCtx( ctx, - command.Plan, + cmd.Name, "", cmd.ProjectName, cmd.Flags, diff --git a/server/events/project_command_context_builder.go b/server/events/project_command_context_builder.go index 05d61fda6d..eadfd93cdb 100644 --- a/server/events/project_command_context_builder.go +++ b/server/events/project_command_context_builder.go @@ -99,7 +99,7 @@ func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext( var steps []valid.Step switch cmdName { - case command.Plan: + case command.Plan, command.DraftPlan: steps = prjCfg.Workflow.Plan.Steps case command.Apply: steps = prjCfg.Workflow.Apply.Steps diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 153269c7e2..af9124e5fd 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -161,7 +161,7 @@ type ProjectOutputWrapper struct { } func (p *ProjectOutputWrapper) Plan(ctx command.ProjectContext) command.ProjectResult { - result := p.updateProjectPRStatus(command.Plan, ctx, p.ProjectCommandRunner.Plan) + result := p.updateProjectPRStatus(ctx.CommandName, ctx, p.ProjectCommandRunner.Plan) p.JobMessageSender.Send(ctx, "", OperationComplete) return result } @@ -225,7 +225,7 @@ type DefaultProjectCommandRunner struct { func (p *DefaultProjectCommandRunner) Plan(ctx command.ProjectContext) command.ProjectResult { planSuccess, failure, err := p.doPlan(ctx) return command.ProjectResult{ - Command: command.Plan, + Command: ctx.CommandName, PlanSuccess: planSuccess, Error: err, Failure: failure, @@ -539,7 +539,8 @@ func (p *DefaultProjectCommandRunner) doPolicyCheck(ctx command.ProjectContext) func (p *DefaultProjectCommandRunner) doPlan(ctx command.ProjectContext) (*models.PlanSuccess, string, error) { // Acquire Atlantis lock for this repo/dir/workspace. - lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), ctx.RepoLocksMode == valid.RepoLocksOnPlanMode) + useRealLock := ctx.RepoLocksMode == valid.RepoLocksOnPlanMode && ctx.CommandName != command.DraftPlan + lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), useRealLock) if err != nil { return nil, "", errors.Wrap(err, "acquiring lock") } @@ -775,7 +776,7 @@ func (p *DefaultProjectCommandRunner) runSteps(steps []valid.Step, ctx command.P switch step.StepName { case "init": out, err = p.InitStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) - case "plan": + case "plan", "draftplan": out, err = p.PlanStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "show": _, err = p.ShowStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go index d241d44569..f22818237b 100644 --- a/server/events/project_command_runner_test.go +++ b/server/events/project_command_runner_test.go @@ -210,8 +210,10 @@ func TestProjectOutputWrapper(t *testing.T) { switch c.CommandName { case command.Plan: + ctx.CommandName = command.Plan runner.Plan(ctx) case command.Apply: + ctx.CommandName = command.Apply runner.Apply(ctx) } diff --git a/server/events/templates/draftplan_success_unwrapped.tmpl b/server/events/templates/draftplan_success_unwrapped.tmpl new file mode 100644 index 0000000000..789ee2a439 --- /dev/null +++ b/server/events/templates/draftplan_success_unwrapped.tmpl @@ -0,0 +1,7 @@ +{{ define "draftplanSuccessUnwrapped" -}} +```diff +{{ if .EnableDiffMarkdownFormat }}{{ .DiffMarkdownFormattedTerraformOutput }}{{ else }}{{ .TerraformOutput }}{{ end }} +``` + +{{ template "mergedAgain" . }} +{{ end -}} diff --git a/server/events/templates/draftplan_success_wrapped.tmpl b/server/events/templates/draftplan_success_wrapped.tmpl new file mode 100644 index 0000000000..72e0609b88 --- /dev/null +++ b/server/events/templates/draftplan_success_wrapped.tmpl @@ -0,0 +1,11 @@ +{{ define "draftplanSuccessWrapped" -}} +
Show Output + +```diff +{{ if .EnableDiffMarkdownFormat }}{{ .DiffMarkdownFormattedTerraformOutput }}{{ else }}{{ .TerraformOutput }}{{ end }} +``` + +
+{{ .PlanSummary -}} +{{ template "mergedAgain" . -}} +{{ end -}} diff --git a/server/events/templates/multi_project_draftplan.tmpl b/server/events/templates/multi_project_draftplan.tmpl new file mode 100644 index 0000000000..a1e232655d --- /dev/null +++ b/server/events/templates/multi_project_draftplan.tmpl @@ -0,0 +1,12 @@ +{{ define "multiProjectDraftPlan" -}} +{{ template "multiProjectHeader" . }} +{{ $disableApplyAll := .DisableApplyAll -}} +{{ $hideUnchangedPlans := .HideUnchangedPlanComments -}} +{{ range $i, $result := .Results -}} +{{ if (and $hideUnchangedPlans $result.NoChanges) }}{{continue}}{{end -}} +### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` +{{ $result.Rendered }} + +{{ end -}} +{{- template "log" . -}} +{{ end -}} diff --git a/server/events/templates/single_project_draftplan_success.tmpl b/server/events/templates/single_project_draftplan_success.tmpl new file mode 100644 index 0000000000..c0ee16829d --- /dev/null +++ b/server/events/templates/single_project_draftplan_success.tmpl @@ -0,0 +1,7 @@ +{{ define "singleProjectDraftPlanSuccess" -}} +{{ $result := index .Results 0 -}} +Ran {{ .Command }} for {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` + +{{ $result.Rendered }} +{{- template "log" . -}} +{{ end -}} diff --git a/server/events/templates/single_project_draftplan_unsuccessful.tmpl b/server/events/templates/single_project_draftplan_unsuccessful.tmpl new file mode 100644 index 0000000000..399ca9abde --- /dev/null +++ b/server/events/templates/single_project_draftplan_unsuccessful.tmpl @@ -0,0 +1,7 @@ +{{ define "singleProjectDraftPlanUnsuccessful" -}} +{{ $result := index .Results 0 -}} +Ran {{ .Command }} for dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` + +{{ $result.Rendered }} +{{- template "log" . -}} +{{ end -}} diff --git a/server/server.go b/server/server.go index af940f3787..626c45d9ef 100644 --- a/server/server.go +++ b/server/server.go @@ -803,6 +803,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ command.Plan: planCommandRunner, + command.DraftPlan: planCommandRunner, command.Apply: applyCommandRunner, command.ApprovePolicies: approvePoliciesCommandRunner, command.Unlock: unlockCommandRunner, diff --git a/server/user_config_test.go b/server/user_config_test.go index 225049f335..bfb6228fd9 100644 --- a/server/user_config_test.go +++ b/server/user_config_test.go @@ -29,14 +29,14 @@ func TestUserConfig_ToAllowCommandNames(t *testing.T) { name: "all", allowCommands: "all", want: []command.Name{ - command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import, command.State, + command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import, command.State, command.DraftPlan, }, }, { name: "all with others returns same with all result", allowCommands: "all,plan", want: []command.Name{ - command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import, command.State, + command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import, command.State, command.DraftPlan, }, }, {