Files
atlantis/server/core/runtime/apply_step_runner.go
2024-05-04 14:59:22 -05:00

254 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runtime
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/pkg/errors"
version "github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/utils"
)
// ApplyStepRunner runs `terraform apply`.
type ApplyStepRunner struct {
TerraformExecutor TerraformExec
DefaultTFVersion *version.Version
CommitStatusUpdater StatusUpdater
AsyncTFExec AsyncTFExec
}
func (a *ApplyStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) {
if a.hasTargetFlag(ctx, extraArgs) {
return "", errors.New("cannot run apply with -target because we are applying an already generated plan. Instead, run -target with atlantis plan")
}
planPath := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName))
contents, err := os.ReadFile(planPath)
if os.IsNotExist(err) {
return "", fmt.Errorf("no plan found at path %q and workspace %qdid you run plan?", ctx.RepoRelDir, ctx.Workspace)
}
if err != nil {
return "", errors.Wrap(err, "unable to read planfile")
}
ctx.Log.Info("starting apply")
var out string
// TODO: Leverage PlanTypeStepRunnerDelegate here
if IsRemotePlan(contents) {
args := append(append([]string{"apply", "-input=false", "-no-color"}, extraArgs...), ctx.EscapedCommentArgs...)
out, err = a.runRemoteApply(ctx, args, path, planPath, ctx.TerraformVersion, envs)
if err == nil {
out = a.cleanRemoteApplyOutput(out)
}
} else {
// NOTE: we need to quote the plan path because Bitbucket Server can
// have spaces in its repo owner names which is part of the path.
args := append(append(append([]string{"apply", "-input=false"}, extraArgs...), ctx.EscapedCommentArgs...), fmt.Sprintf("%q", planPath))
out, err = a.TerraformExecutor.RunCommandWithVersion(ctx, path, args, envs, ctx.TerraformVersion, ctx.Workspace)
}
// If the apply was successful, delete the plan.
if err == nil {
ctx.Log.Info("apply successful, deleting planfile")
if removeErr := utils.RemoveIgnoreNonExistent(planPath); removeErr != nil {
ctx.Log.Warn("failed to delete planfile after successful apply: %s", removeErr)
}
}
return out, err
}
func (a *ApplyStepRunner) hasTargetFlag(ctx command.ProjectContext, extraArgs []string) bool {
isTargetFlag := func(s string) bool {
if s == "-target" {
return true
}
split := strings.Split(s, "=")
return split[0] == "-target"
}
for _, arg := range ctx.EscapedCommentArgs {
if isTargetFlag(arg) {
return true
}
}
for _, arg := range extraArgs {
if isTargetFlag(arg) {
return true
}
}
return false
}
// cleanRemoteApplyOutput removes unneeded output like the refresh and plan
// phases to make the final comment cleaner.
func (a *ApplyStepRunner) cleanRemoteApplyOutput(out string) string {
applyStartText := ` Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:
`
applyStartIdx := strings.Index(out, applyStartText)
if applyStartIdx < 0 {
return out
}
return out[applyStartIdx+len(applyStartText):]
}
// runRemoteApply handles running the apply and performing actions in real-time
// as we get the output from the command.
// Specifically, we set commit statuses with links to Terraform Enterprise's
// UI to view real-time output.
// We also check if the plan that's about to be applied matches the one we
// printed to the pull request.
// We need to do this because remote plan doesn't support -out, so we do a
// manual diff.
// It also writes "yes" or "no" to the process to confirm the apply.
func (a *ApplyStepRunner) runRemoteApply(
ctx command.ProjectContext,
applyArgs []string,
path string,
absPlanPath string,
tfVersion *version.Version,
envs map[string]string) (string, error) {
// The planfile contents are needed to ensure that the plan didn't change
// between plan and apply phases.
planfileBytes, err := os.ReadFile(absPlanPath)
if err != nil {
return "", errors.Wrap(err, "reading planfile")
}
// updateStatusF will update the commit status and log any error.
updateStatusF := func(status models.CommitStatus, url string) {
if err := a.CommitStatusUpdater.UpdateProject(ctx, command.Apply, status, url, nil); err != nil {
ctx.Log.Err("unable to update status: %s", err)
}
}
// Start the async command execution.
ctx.Log.Debug("starting async tf remote operation")
inCh, outCh := a.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), applyArgs, envs, tfVersion, ctx.Workspace)
var lines []string
nextLineIsRunURL := false
var runURL string
var planChangedErr error
for line := range outCh {
if line.Err != nil {
err = line.Err
break
}
lines = append(lines, line.Line)
// Here we're checking for the run url and updating the status
// if found.
if line.Line == lineBeforeRunURL {
nextLineIsRunURL = true
} else if nextLineIsRunURL {
runURL = strings.TrimSpace(line.Line)
ctx.Log.Debug("remote run url found, updating commit status")
updateStatusF(models.PendingCommitStatus, runURL)
nextLineIsRunURL = false
}
// If the plan is complete and it's waiting for us to verify the apply,
// check if the plan is the same and if so, input "yes".
if a.atConfirmApplyPrompt(lines) {
ctx.Log.Debug("remote apply is waiting for confirmation")
// Check if the plan is as expected.
planChangedErr = a.remotePlanChanged(string(planfileBytes), strings.Join(lines, "\n"), tfVersion)
if planChangedErr != nil {
ctx.Log.Err("plan generated during apply does not match expected plan, aborting")
inCh <- "no\n"
// Need to continue so we read all the lines, otherwise channel
// sender (in TerraformClient) will block indefinitely waiting
// for us to read.
continue
}
ctx.Log.Debug("plan generated during apply matches expected plan, continuing")
inCh <- "yes\n"
}
}
ctx.Log.Debug("async tf remote operation complete")
output := strings.Join(lines, "\n")
if planChangedErr != nil {
updateStatusF(models.FailedCommitStatus, runURL)
// The output isn't important if the plans don't match so we just
// discard it.
return "", planChangedErr
}
if err != nil {
updateStatusF(models.FailedCommitStatus, runURL)
} else {
updateStatusF(models.SuccessCommitStatus, runURL)
}
return output, err
}
// remotePlanChanged checks if the plan generated during the plan phase matches
// the one we're about to apply in the apply phase.
// If the plans don't match, it returns an error with a diff of the two plans
// that can be printed to the pull request.
func (a *ApplyStepRunner) remotePlanChanged(planfileContents string, applyOut string, tfVersion *version.Version) error {
output := StripRefreshingFromPlanOutput(applyOut, tfVersion)
// Strip plan output after the prompt to execute the plan.
planEndIdx := strings.Index(output, "Do you want to perform these actions in workspace \"")
if planEndIdx < 0 {
return fmt.Errorf("Couldn't find plan end when parsing apply output:\n%q", applyOut)
}
currPlan := strings.TrimSpace(output[:planEndIdx])
// Ensure we strip the remoteOpsHeader from the plan contents so the
// comparison is fair. We add this header in the plan phase so we can
// identify that this planfile came from a remote plan.
expPlan := strings.TrimSpace(planfileContents[len(remoteOpsHeader):])
if currPlan != expPlan {
return fmt.Errorf(planChangedErrFmt, expPlan, currPlan)
}
return nil
}
// atConfirmApplyPrompt returns true if the apply is at the "confirm this apply" step.
// This is determined by looking at the current command output provided by
// applyLines.
func (a *ApplyStepRunner) atConfirmApplyPrompt(applyLines []string) bool {
waitingMatchLines := strings.Split(waitingForConfirmation, "\n")
return len(applyLines) >= len(waitingMatchLines) && reflect.DeepEqual(applyLines[len(applyLines)-len(waitingMatchLines):], waitingMatchLines)
}
// planChangedErrFmt is the error we print to pull requests when the plan changed
// between remote terraform plan and apply phases.
var planChangedErrFmt = `Plan generated during apply phase did not match plan generated during plan phase.
Aborting apply.
Expected Plan:
%s
**************************************************
Actual Plan:
%s
**************************************************
This likely occurred because someone applied a change to this state in-between
your plan and apply commands.
To resolve, re-run plan.`
// waitingForConfirmation is what is printed during a remote apply when
// terraform is waiting for confirmation to apply the plan.
var waitingForConfirmation = ` Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.`