mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 19:38:24 +00:00
254 lines
8.6 KiB
Go
254 lines
8.6 KiB
Go
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 %q–did 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.`
|