Files
atlantis/server/events/plan_command_runner.go
2025-03-10 22:08:22 +00:00

377 lines
14 KiB
Go

package events
import (
"github.com/runatlantis/atlantis/server/core/locking"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
)
func NewPlanCommandRunner(
silenceVCSStatusNoPlans bool,
silenceVCSStatusNoProjects bool,
vcsClient vcs.Client,
pendingPlanFinder PendingPlanFinder,
workingDir WorkingDir,
commitStatusUpdater CommitStatusUpdater,
projectCommandBuilder ProjectPlanCommandBuilder,
projectCommandRunner ProjectPlanCommandRunner,
dbUpdater *DBUpdater,
pullUpdater *PullUpdater,
policyCheckCommandRunner *PolicyCheckCommandRunner,
autoMerger *AutoMerger,
parallelPoolSize int,
SilenceNoProjects bool,
pullStatusFetcher PullStatusFetcher,
lockingLocker locking.Locker,
discardApprovalOnPlan bool,
pullReqStatusFetcher vcs.PullReqStatusFetcher,
) *PlanCommandRunner {
return &PlanCommandRunner{
silenceVCSStatusNoPlans: silenceVCSStatusNoPlans,
silenceVCSStatusNoProjects: silenceVCSStatusNoProjects,
vcsClient: vcsClient,
pendingPlanFinder: pendingPlanFinder,
workingDir: workingDir,
commitStatusUpdater: commitStatusUpdater,
prjCmdBuilder: projectCommandBuilder,
prjCmdRunner: projectCommandRunner,
dbUpdater: dbUpdater,
pullUpdater: pullUpdater,
policyCheckCommandRunner: policyCheckCommandRunner,
autoMerger: autoMerger,
parallelPoolSize: parallelPoolSize,
SilenceNoProjects: SilenceNoProjects,
pullStatusFetcher: pullStatusFetcher,
lockingLocker: lockingLocker,
DiscardApprovalOnPlan: discardApprovalOnPlan,
pullReqStatusFetcher: pullReqStatusFetcher,
}
}
type PlanCommandRunner struct {
vcsClient vcs.Client
// SilenceNoProjects is whether Atlantis should respond to PRs if no projects
// are found
SilenceNoProjects bool
// SilenceVCSStatusNoPlans is whether autoplan should set commit status if no plans
// are found
silenceVCSStatusNoPlans bool
// SilenceVCSStatusNoPlans is whether any plan should set commit status if no projects
// are found
silenceVCSStatusNoProjects bool
commitStatusUpdater CommitStatusUpdater
pendingPlanFinder PendingPlanFinder
workingDir WorkingDir
prjCmdBuilder ProjectPlanCommandBuilder
prjCmdRunner ProjectPlanCommandRunner
dbUpdater *DBUpdater
pullUpdater *PullUpdater
policyCheckCommandRunner *PolicyCheckCommandRunner
autoMerger *AutoMerger
parallelPoolSize int
pullStatusFetcher PullStatusFetcher
lockingLocker locking.Locker
// DiscardApprovalOnPlan controls if all already existing approvals should be removed/dismissed before executing
// a plan.
DiscardApprovalOnPlan bool
pullReqStatusFetcher vcs.PullReqStatusFetcher
SilencePRComments []string
}
func (p *PlanCommandRunner) runAutoplan(ctx *command.Context) {
baseRepo := ctx.Pull.BaseRepo
pull := ctx.Pull
projectCmds, err := p.prjCmdBuilder.BuildAutoplanCommands(ctx)
if err != nil {
if statusErr := p.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.FailedCommitStatus, command.Plan); statusErr != nil {
ctx.Log.Warn("unable to update commit status: %s", statusErr)
}
p.pullUpdater.updatePull(ctx, AutoplanCommand{}, command.Result{Error: err})
return
}
projectCmds, policyCheckCmds := p.partitionProjectCmds(ctx, projectCmds)
if len(projectCmds) == 0 {
ctx.Log.Info("determined there was no project to run plan in")
if !(p.silenceVCSStatusNoPlans || p.silenceVCSStatusNoProjects) {
// If there were no projects modified, we set successful commit statuses
// with 0/0 projects planned/policy_checked/applied successfully because some users require
// the Atlantis status to be passing for all pull requests.
ctx.Log.Debug("setting VCS status to success with no projects found")
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
}
return
}
// discard previous plans that might not be relevant anymore
ctx.Log.Debug("deleting previous plans and locks")
p.deletePlans(ctx)
_, err = p.lockingLocker.UnlockByPull(baseRepo.FullName, pull.Num)
if err != nil {
ctx.Log.Err("deleting locks: %s", err)
}
// Only run commands in parallel if enabled
var result command.Result
if p.isParallelEnabled(projectCmds) {
ctx.Log.Info("Running plans in parallel")
result = runProjectCmdsParallelGroups(ctx, projectCmds, p.prjCmdRunner.Plan, p.parallelPoolSize)
} else {
result = runProjectCmds(projectCmds, p.prjCmdRunner.Plan)
}
if p.autoMerger.automergeEnabled(projectCmds) && result.HasErrors() {
ctx.Log.Info("deleting plans because there were errors and automerge requires all plans succeed")
p.deletePlans(ctx)
result.PlansDeleted = true
}
p.pullUpdater.updatePull(ctx, AutoplanCommand{}, result)
pullStatus, err := p.dbUpdater.updateDB(ctx, ctx.Pull, result.ProjectResults)
if err != nil {
ctx.Log.Err("writing results: %s", err)
}
p.updateCommitStatus(ctx, pullStatus, command.Plan)
p.updateCommitStatus(ctx, pullStatus, command.Apply)
// Check if there are any planned projects and if there are any errors or if plans are being deleted
if len(policyCheckCmds) > 0 &&
!(result.HasErrors() || result.PlansDeleted) {
// Run policy_check command
ctx.Log.Info("Running policy_checks for all plans")
// refresh ctx's view of pull status since we just wrote to it.
// realistically each command should refresh this at the start,
// however, policy checking is weird since it's called within the plan command itself
// we need to better structure how this command works.
ctx.PullStatus = &pullStatus
p.policyCheckCommandRunner.Run(ctx, policyCheckCmds)
}
}
func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) {
var err error
baseRepo := ctx.Pull.BaseRepo
pull := ctx.Pull
ctx.PullRequestStatus, err = p.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull)
if err != nil {
// On error we continue the request with mergeable assumed false.
// We want to continue because not all apply's will need this status,
// only if they rely on the mergeability requirement.
// All PullRequestStatus fields are set to false by default when error.
ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err)
}
if p.DiscardApprovalOnPlan {
if err = p.pullUpdater.VCSClient.DiscardReviews(ctx.Log, baseRepo, pull); err != nil {
ctx.Log.Err("failed to remove approvals: %s", err)
}
}
projectCmds, err := p.prjCmdBuilder.BuildPlanCommands(ctx, cmd)
if err != nil {
if statusErr := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); statusErr != nil {
ctx.Log.Warn("unable to update commit status: %s", statusErr)
}
p.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err})
return
}
if len(projectCmds) == 0 && p.SilenceNoProjects {
ctx.Log.Info("determined there was no project to run plan in")
if !p.silenceVCSStatusNoProjects {
if cmd.IsForSpecificProject() {
// With a specific plan, just reset the status so it's not stuck in pending state
pullStatus, err := p.pullStatusFetcher.GetPullStatus(pull)
if err != nil {
ctx.Log.Warn("unable to fetch pull status: %s", err)
return
}
if pullStatus == nil {
// default to 0/0
ctx.Log.Debug("setting VCS status to 0/0 success as no previous state was found")
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
return
}
ctx.Log.Debug("resetting VCS status")
p.updateCommitStatus(ctx, *pullStatus, command.Plan)
} else {
// With a generic plan, we set successful commit statuses
// with 0/0 projects planned successfully because some users require
// the Atlantis status to be passing for all pull requests.
// Does not apply to skipped runs for specific projects
ctx.Log.Debug("setting VCS status to success with no projects found")
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
}
}
return
}
projectCmds, policyCheckCmds := p.partitionProjectCmds(ctx, projectCmds)
// if the plan is generic, new plans will be generated based on changes
// discard previous plans that might not be relevant anymore
if !cmd.IsForSpecificProject() {
ctx.Log.Debug("deleting previous plans and locks")
p.deletePlans(ctx)
_, err = p.lockingLocker.UnlockByPull(baseRepo.FullName, pull.Num)
if err != nil {
ctx.Log.Err("deleting locks: %s", err)
}
}
// Only run commands in parallel if enabled
var result command.Result
if p.isParallelEnabled(projectCmds) {
ctx.Log.Info("Running plans in parallel")
result = runProjectCmdsParallelGroups(ctx, projectCmds, p.prjCmdRunner.Plan, p.parallelPoolSize)
} else {
result = runProjectCmds(projectCmds, p.prjCmdRunner.Plan)
}
ctx.CommandHasErrors = result.HasErrors()
if p.autoMerger.automergeEnabled(projectCmds) && result.HasErrors() {
ctx.Log.Info("deleting plans because there were errors and automerge requires all plans succeed")
p.deletePlans(ctx)
result.PlansDeleted = true
}
p.pullUpdater.updatePull(
ctx,
cmd,
result)
pullStatus, err := p.dbUpdater.updateDB(ctx, pull, result.ProjectResults)
if err != nil {
ctx.Log.Err("writing results: %s", err)
return
}
p.updateCommitStatus(ctx, pullStatus, command.Plan)
p.updateCommitStatus(ctx, pullStatus, command.Apply)
// Runs policy checks step after all plans are successful.
// This step does not approve any policies that require approval.
if len(result.ProjectResults) > 0 &&
!(result.HasErrors() || result.PlansDeleted) {
ctx.Log.Info("Running policy check for '%s'", cmd.CommandName())
p.policyCheckCommandRunner.Run(ctx, policyCheckCmds)
} else if len(projectCmds) == 0 && !cmd.IsForSpecificProject() {
// If there were no projects modified, we set successful commit statuses
// with 0/0 projects planned/policy_checked/applied successfully because some users require
// the Atlantis status to be passing for all pull requests.
ctx.Log.Debug("setting VCS status to success with no projects found")
if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
}
}
func (p *PlanCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) {
if ctx.Trigger == command.AutoTrigger {
p.runAutoplan(ctx)
} else {
p.run(ctx, cmd)
}
}
func (p *PlanCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus, commandName command.Name) {
var numSuccess int
var numErrored int
status := models.SuccessCommitStatus
if commandName == command.Plan {
numErrored = pullStatus.StatusCount(models.ErroredPlanStatus)
// We consider anything that isn't a plan error as a plan success.
// For example, if there is an apply error, that means that at least a
// plan was generated successfully.
numSuccess = len(pullStatus.Projects) - numErrored
if numErrored > 0 {
status = models.FailedCommitStatus
}
} else if commandName == command.Apply {
numSuccess = pullStatus.StatusCount(models.AppliedPlanStatus) + pullStatus.StatusCount(models.PlannedNoChangesPlanStatus)
numErrored = pullStatus.StatusCount(models.ErroredApplyStatus)
if numErrored > 0 {
status = models.FailedCommitStatus
} else if numSuccess < len(pullStatus.Projects) {
// If there are plans that haven't been applied yet, no need to update the status
return
}
}
if err := p.commitStatusUpdater.UpdateCombinedCount(
ctx.Log,
ctx.Pull.BaseRepo,
ctx.Pull,
status,
commandName,
numSuccess,
len(pullStatus.Projects),
); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
}
// deletePlans deletes all plans generated in this ctx.
func (p *PlanCommandRunner) deletePlans(ctx *command.Context) {
pullDir, err := p.workingDir.GetPullDir(ctx.Pull.BaseRepo, ctx.Pull)
if err != nil {
ctx.Log.Err("getting pull dir: %s", err)
}
if err := p.pendingPlanFinder.DeletePlans(pullDir); err != nil {
ctx.Log.Err("deleting pending plans: %s", err)
}
}
func (p *PlanCommandRunner) partitionProjectCmds(
ctx *command.Context,
cmds []command.ProjectContext,
) (
projectCmds []command.ProjectContext,
policyCheckCmds []command.ProjectContext,
) {
for _, cmd := range cmds {
switch cmd.CommandName {
case command.Plan:
projectCmds = append(projectCmds, cmd)
case command.PolicyCheck:
policyCheckCmds = append(policyCheckCmds, cmd)
default:
ctx.Log.Err("%s is not supported", cmd.CommandName)
}
}
return
}
func (p *PlanCommandRunner) isParallelEnabled(projectCmds []command.ProjectContext) bool {
return len(projectCmds) > 0 && projectCmds[0].ParallelPlanEnabled
}