Skip to content

Commit

Permalink
Add new draftplan command.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
finnag committed Oct 8, 2024
1 parent 03e9e71 commit fae4425
Show file tree
Hide file tree
Showing 25 changed files with 208 additions and 56 deletions.
2 changes: 1 addition & 1 deletion cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions server/controllers/events/events_controller_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 31 additions & 20 deletions server/core/runtime/plan_step_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}
Expand Down
1 change: 1 addition & 0 deletions server/core/runtime/plan_step_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
7 changes: 7 additions & 0 deletions server/events/command/name.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -47,6 +49,7 @@ var AllCommentCommands = []Name{
ApprovePolicies,
Import,
State,
DraftPlan,
}

// TitleString returns the string representation in title form.
Expand Down Expand Up @@ -74,6 +77,8 @@ func (c Name) String() string {
return "import"
case State:
return "state"
case DraftPlan:
return "draftplan"
}
return ""
}
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion server/events/command/project_result.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
2 changes: 1 addition & 1 deletion server/events/command_runner_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions server/events/command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions server/events/comment_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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()),
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 46 additions & 1 deletion server/events/comment_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions server/events/commit_status_updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), "")
Expand Down
2 changes: 1 addition & 1 deletion server/events/instrumented_project_command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
24 changes: 20 additions & 4 deletions server/events/markdown_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down
Loading

0 comments on commit fae4425

Please sign in to comment.