Skip to content

fix: only require a managed plan file for managed apply workflows - #6781

Merged
jamengual merged 2 commits into
mainfrom
fix/custom-apply-plan-path-6642
Aug 18, 2026
Merged

fix: only require a managed plan file for managed apply workflows#6781
jamengual merged 2 commits into
mainfrom
fix/custom-apply-plan-path-6642

Conversation

@jamengual

Copy link
Copy Markdown
Contributor

Fixes #6642.

What broke

ApplyPlanValidator was introduced in #6605 and first released in v0.46.0, which matches the bisect reported in #6642 (0.43.0 fine, 0.46.0 broken).

doApply validates the plan for every project before any apply step executes:

// server/events/project_command_runner.go
if p.ApplyPlanValidator != nil {
    if err := p.ApplyPlanValidator.ValidateProjectPlan(ctx, absPath); err != nil {
        return "", "", "", err
    }
}

The path it validates is the Atlantis convention artifact, derived from runtime.GetPlanFilename:

func GetPlanFilename(workspace string, projName string) string {
	if projName == "" { return fmt.Sprintf("%s.tfplan", workspace) }
	...
}

A workflow whose plan and apply are built entirely from custom run steps writes its plan wherever its commands choose — atlantis.tfplan in the reported case. That project has no <workspace>.tfplan, so apply failed with:

plan file is missing for dir "." workspace "default" project ""; run `atlantis plan`

The same assumption broke a second path immediately below: when ExpectedPlanHash was empty, doApply called hashFile on that same nonexistent convention path to pin it for the rest of the command.

Note the sibling call inside the built-in apply step case is correct — it only runs for managed applies. The bug is that the doApply call fires for everyone.

The fix

Derive whether Atlantis owns the plan artifact from the workflow shape. Atlantis owns it when the workflow uses the built-in plan step (Atlantis writes the file) or the built-in apply step (Atlantis reads it):

func requiresAtlantisManagedPlanFile(workflow valid.Workflow) bool {
	return hasAtlantisManagedPlanStep(workflow.Plan.Steps) || hasAtlantisManagedApplyStep(workflow.Apply.Steps)
}

Workflows built only from custom run steps manage their own plan file, so Atlantis no longer requires, hashes, or removes one for them.

Durable plan state is still validated for every project. ValidateProjectPlan is split so custom-plan-path workflows go through the new ValidateProjectPlanStatus, which still enforces recorded plan status, apply eligibility, and head/base identity — it simply does not touch an artifact on disk.

Artifact removal on rejection is unchanged for managed workflows. The status failures that discarded the plan file before still do; stale command head errors still leave it in place so a concurrent replica's plan is not deleted, and errStaleCommandHead remains unwrapped so the errors.Is short-circuit in apply_command_runner keeps working.

The gate fails closed — the steps actually being executed are authoritative, so a ProjectContext built outside the builder (targeted apply, API paths, tests) still validates the plan file when a built-in apply step will read it:

func requiresManagedPlanFileForApply(ctx command.ProjectContext) bool {
	return ctx.RequiresAtlantisManagedPlanFile || hasAtlantisManagedApplyStep(ctx.Steps)
}

An earlier revision of this branch gated only on the context field; the existing TestProjectCommandRunner_ApplyRejectsPlanDeletedAfterBuilderValidation caught that a directly-constructed context defaulted to false and skipped validation. Hence the fail-closed form.

Tests

Verification

go build ./...                    clean
go vet ./server/... ./cmd/...     clean
gofmt -l server/                  clean
go test -race ./server/events/    ok, 0 failures (matches main baseline)

The -race result was compared against a main worktree run under identical scope.

Review note

project_command_context_builder.go shows ~132 changed lines, but that is gofmt struct-literal realignment around a single added field. Please review it with git diff -w, which reduces it to the one real edit plus the new predicates.

Scope

Deliberately minimal and backportable to 0.46.x — no dependency on plan generations, publication claims, or S3 changes.

This extracts the #6642 regression fix from #6657, which bundles it with a much larger durable-plan-state effort (plan generations, a publication claim, S3 digest binding, validated snapshots, a recovery CLI). That work is worth pursuing separately; this PR is only the regression.

Two follow-ups intentionally left out:

  1. rejectProjectPlan deletes the plan file when validation fails. That is aggressive independent of this bug and predates it, so it is not touched here.
  2. TestBuildProjectCmdCtx compares whole ProjectContext values, so adding any field produces a multi-hundred-megabyte failure diff that reads as a hung test rather than a failure. Worth making that comparison field-scoped before more fields land.

Atlantis 0.46.0 (#6605) added apply-time plan validation that runs in
doApply for every project, before any apply step executes. The validation
targets the Atlantis convention plan artifact, <workspace>.tfplan.

A workflow whose plan and apply are built entirely from custom run steps
writes its plan wherever its commands choose, for example atlantis.tfplan.
For those projects the convention file does not exist, so apply failed with:

    plan file is missing for dir "." workspace "default" project "";
    run `atlantis plan`

The same assumption broke a second path: when ExpectedPlanHash was empty,
doApply hashed the convention plan file to pin it for the remainder of the
command, which also fails when that file was never created.

Derive whether Atlantis owns the plan artifact from the workflow shape. A
workflow owns it when it uses the built-in plan step (Atlantis writes the
file) or the built-in apply step (Atlantis reads it). Workflows built only
from custom run steps manage their own plan file, so Atlantis no longer
requires, hashes, or removes one for them.

Durable plan state is still validated for every project. ValidateProjectPlan
is split so custom-plan-path workflows go through ValidateProjectPlanStatus,
which checks recorded plan status, apply eligibility, head and base identity,
without touching an artifact on disk. Artifact removal on rejection is
unchanged for managed workflows: the status failures that discarded the plan
file before still do, and stale command head errors still leave it in place
so a concurrent replica's plan is not deleted.

Fixes #6642
@dosubot dosubot Bot added bug Something isn't working go Pull requests that update Go code labels Aug 18, 2026
@github-actions github-actions Bot added docs Documentation size/m labels Aug 18, 2026
@github-code-quality

Copy link
Copy Markdown

Code Coverage Overview

Languages: Go

Go / code-coverage/go

The overall coverage in commit a25eb23 in the fix/custom-apply-pla... branch is 73%. Coverage data for the main branch is not yet available.

Show a code coverage summary of the most covered files.
File main fix/custom-apply-pla... a25eb23 +/-
server/events/v...ithub/client.go 88%
server/core/dri.../remediation.go 86%
server/controll...i_controller.go 83%
server/events/p...mand_builder.go 83%
server/events/v...itlab/client.go 80%
server/events/p...mmand_runner.go 76%
server/events/event_parser.go 76%
server/events/working_dir.go 73%
server/server.go 70%
server/controll...s_controller.go 64%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working docs Documentation go Pull requests that update Go code size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression (v0.46.0): atlantis apply fails with "plan file is missing" for custom workflows

3 participants