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.`