Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: draftplan #3275

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,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 @@ -244,6 +244,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 @@ -510,6 +518,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 @@ -519,6 +528,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 @@ -560,6 +570,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 @@ -885,6 +917,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 @@ -1029,6 +1065,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
Loading