A Terraform process can return success while the release still fails acceptance. The misleading case is simple: reviewers approve one configuration, the working tree later describes another, and an executor supplies Terraform with a previously saved plan. A green terraform apply then answers only one question: did Terraform successfully execute the supplied artifact? It does not, by itself, prove that the artifact was the configuration revision currently authorized for release.
This playbook isolates that distinction with no cloud account, external provider, provisioner, remote API, production state, credential or paid resource. The fixture uses one local backend and terraform_data, whose built-in provider requires no external provider configuration. HashiCorp documents saved plans as executable artifacts containing configuration and planning inputs, and saved-plan apply mode executes the decisions already stored in that plan rather than automatically constructing a new one.
The canonical sequence is A → plan B → edit source to C → deliberately apply the old B plan in a disposable negative case. The documented-behavior prediction is that the saved artifact represents B, even while the source tree says C. The independent approval record will say C is now authorized, so a successful B apply would be a process success but a release-acceptance failure. A new plan from C should then expose the remaining B-to-C difference. No terminal transcript below is represented as an observed run.
The HashiCorp pages cited here are living documentation; no publication date is asserted for those pages.
Define the authorized configuration, not just the desired directory
Start with the approval record, not git status.
The acceptance boundary is a relationship among four identities:
Identity | Evidence | Question it answers |
Source | Git revision plus SHA-256 of the fixture source | Which configuration was reviewed? |
Plan | SHA-256 of the saved .tfplan bytes plus terraform show views | Which executable artifact was approved? |
Execution context | Terraform build and executable digest, OS/architecture, shell, working directory and workspace | Is this the declared environment for executing that artifact? |
Authority | Independent approval manifest with decision and supersession state | Is this plan still permitted to run? |
That model is deliberately narrower than general infrastructure-as-code foundations. The purpose here is not to reteach provisioning, state or HCL. It is to determine whether an executable Terraform plan and the currently authorized configuration still refer to the same release intent.
HashiCorp’s living terraform plan reference says -out=FILE writes an opaque saved plan that can later be passed to terraform apply; it also says the saved file contains the full configuration, planned values and plan options, including input variables. HashiCorp’s terraform apply reference distinguishes automatic-plan mode from saved-plan mode: supplying a saved plan tells Terraform to perform the operations in that plan, and additional planning modes or options cannot be attached to revise those already-made decisions.
That leads to four operational decisions.
Apply only when the artifact bytes, reviewed source, execution context and active approval all match.
Re-plan when authorized intent has changed and the existing artifact therefore represents an obsolete decision.
Hold when identity or authority is missing, contradictory or unverifiable. A digest proves byte equality, not who created or approved those bytes.
Recover when an obsolete artifact has already been applied. Recovery starts from actual current state and a newly selected target; it does not start by pretending the previous apply did not happen.
A dirty working tree can be policy evidence, but it is not proof that Terraform itself must reject a saved plan. Conversely, a clean tree is not proof that the plan is authorized: the tree can be clean at C while the executable artifact is still B. Nor should every organization equate “newest checkout” with authority. A deliberately approved older revision can be valid if policy says so and the compatible execution context is explicit.
Pin a local Terraform execution baseline
Use a new disposable root. Do not point this fixture at an existing Terraform directory.
This playbook pins Terraform v1.16.4 as the fixture CLI version. The choice is a reproducibility baseline, not a statement that v1.16.4 is the newest Terraform release. HashiCorp’s official v1.16.4 release archive contains platform-specific binaries and a published checksum file. The driver must additionally hash the installed executable itself; a ZIP-file checksum is not a substitute for the digest of the binary actually invoked.
The lab stays deliberately smaller than a survey of Terraform within a DevOps toolchain. It requires only Bash, Git, Python 3 for local JSON/hash handling, and the pinned Terraform CLI.
Use this layout:
terraform-saved-plan-lab/
├── apply-gate.sh
├── run-case.sh
├── variants/
│ ├── A/main.tf
│ ├── B/main.tf
│ └── C/main.tf
└── runs/ # generated; every case gets its own rootThe fixture uses Terraform’s local backend. HashiCorp documents that backend as storing state on the local filesystem, locking it with system APIs and performing operations locally. Nothing in this experiment should refer to an existing backend, environment account or production state.
terraform_data is particularly useful here because HashiCorp documents it as a normal managed resource available through the built-in terraform.io/builtin/terraform provider without configuring an external provider; its input is stored in state and its output derives from that input.
That distinction matters for dependency evidence. Terraform’s dependency-lock documentation says .terraform.lock.hcl currently tracks provider dependencies, meaning external provider selections. This fixture has no external provider dependency. Record that no external provider lock entry is expected for the built-in provider. Do not fabricate a provider lock file merely to make the evidence bundle look more complete.
Before any state is created, record:
terraform version -json
command -v terraform
sha256sum "$(command -v terraform)" # Linux example; driver below is portable
uname -a
printf '%s\n' "$BASH_VERSION"
pwd -PAfter terraform init, additionally record:
terraform workspace show
git rev-parse HEADThe important distinction is that the Terraform version identifies the executor, while the fixture Git revision identifies configuration source. They are independent evidence fields.
For each plan/apply boundary, preserve at minimum:
terraform_version
terraform_executable_sha256
os_arch
shell
fixture_commit
working_directory
workspace
source_digest_sha256
plan_digest_sha256HashiCorp’s automation guidance warns that moving plan and apply between machines introduces compatibility conditions: the archived working directory may need the same absolute path, the operating system and CPU architecture must be compatible, and Terraform/provider versions must remain suitable for interpreting the plan. This lab therefore chooses a stricter rule: plan and approved apply use the same canonical working directory and binary digest. Another organization can define a different compatible staging model, but that allowance must be explicit rather than inferred from whichever checkout happens to be present.
Create A and independently declare B
Create variants/A/main.tf exactly as follows:
terraform {
required_version = "= 1.16.4"
backend "local" {
path = "lab.tfstate"
}
}
resource "terraform_data" "release" {
input = "A"
}
output "release_revision" {
value = terraform_data.release.output
}Create variants/B/main.tf:
terraform {
required_version = "= 1.16.4"
backend "local" {
path = "lab.tfstate"
}
}
resource "terraform_data" "release" {
input = "B"
}
output "release_revision" {
value = terraform_data.release.output
}A clean case root begins with A, initializes only the local backend and applies A:
mkdir -p runs/negative-old-b-after-c/repo
cd runs/negative-old-b-after-c/repo
git init
git config user.name "Terraform Saved Plan Lab"
git config user.email "[email protected]"
cp ../../../variants/A/main.tf main.tf
printf '.terraform/\nlab.tfstate\nlab.tfstate.*\n' > .gitignore
git add main.tf .gitignore
git commit -m "fixture: release A"
terraform init -input=false -no-color
terraform apply \
-input=false \
-auto-approve \
-no-color \
>../evidence/apply-a.stdout \
2>../evidence/apply-a.stderr
apply_a_rc=$?
printf '%s\n' "$apply_a_rc" >../evidence/apply-a.exitcodeA real driver must stop if that apply does not return zero. There is no need to invent sample output. Inspect what actually exists:
terraform show -no-color \
>../evidence/after-a.state.txt \
2>../evidence/after-a.state.txt.stderr
terraform show -json \
>../evidence/after-a.state.json \
2>../evidence/after-a.state.json.stderr
terraform output -raw release_revision \
>../evidence/after-a.output.txt \
2>../evidence/after-a.output.stderrNow replace A with B and commit it:
cp ../../../variants/B/main.tf main.tf
git add main.tf
git commit -m "fixture: release B"
git rev-parse HEAD >../evidence/fixture-b.commitBefore generating any plan, create an expected-change record. Its purpose is to prevent reviewers from learning the “expected” answer from the artifact they are supposedly validating.
Conceptually:
{
"expected_from": "A",
"expected_to": "B",
"source_revision": "<B Git commit>",
"source_digest_sha256": "<SHA-256 of B main.tf>",
"evidence_class": "pre-plan expectation"
}The angle brackets are placeholders because Git object IDs and byte digests are properties of the real fixture you create. They are not publishable evidence until the driver replaces them with computed values.
The expected comparison is now mathematically simple: state contains A; source contains B; therefore a normal plan should have a non-empty difference. HashiCorp documents normal planning as comparing the current configuration with prior state and proposing actions to align managed objects with configuration. For this inert terraform_data fixture there is no remote system to drift independently.
Save B as an executable artifact
Generate B with both -out and -detailed-exitcode:
set +e
terraform plan \
-input=false \
-no-color \
-detailed-exitcode \
-out=../artifacts/plan-b.tfplan \
>../evidence/plan-b.stdout \
2>../evidence/plan-b.stderr
plan_b_rc=$?
set -e
printf '%s\n' "$plan_b_rc" >../evidence/plan-b.exitcodeDo not collapse stdout and stderr, and do not write:
terraform plan ... | tee plan.log
echo $?unless you deliberately handle pipeline status. Otherwise the reported code can belong to tee, not Terraform.
HashiCorp defines the three -detailed-exitcode outcomes as:
Exit | Meaning | Acceptance interpretation |
0 | Plan succeeded; empty diff | Success with no proposed changes |
1 | Terraform error | Plan failed |
2 | Plan succeeded; non-empty diff | Success with proposed changes |
A value of 2 is therefore not a failed plan. In this A-to-B case, 2 is the documented-behavior expectation. If the real run produces 0, 1 or any contradictory inspection result, preserve that conflict and hold rather than editing the evidence to fit the prediction.
Keep the executable binary untouched and inspect it into separate files:
terraform show -no-color ../artifacts/plan-b.tfplan \
>../evidence/plan-b.show.txt \
2>../evidence/plan-b.show.txt.stderr
terraform show -json ../artifacts/plan-b.tfplan \
>../evidence/plan-b.show.json \
2>../evidence/plan-b.show.json.stderr
sha256sum ../artifacts/plan-b.tfplan \
>../evidence/plan-b.plan.sha256HashiCorp’s living terraform show reference says the command can render a saved plan in human-readable form and that -json emits a machine-readable representation of the plan, its configuration and current state. It also warns that JSON representations can expose sensitive values. This fixture contains only inert letters, but the retention rule should still treat saved plans and JSON inspections as restricted artifacts because the same commands used elsewhere can capture secrets. HashiCorp similarly warns that saved plans can contain configuration and sensitive values in cleartext.
The binary, human inspection and JSON inspection are three different artifacts:
artifacts/plan-b.tfplan
evidence/plan-b.show.txt
evidence/plan-b.show.jsonDo not overwrite the binary with a new plan while retaining the old review record.
The approval manifest must bind review to exact bytes, not a friendly filename such as latest.tfplan. A complete active-B record should contain fields of this form:
{
"schema": "terraform-plan-approval/v1",
"authorized_source_revision": "<B Git commit>",
"authorized_source_digest_sha256": "<B main.tf SHA-256>",
"source_digest_scope": "main.tf bytes",
"plan_digest_sha256": "<plan-b.tfplan SHA-256>",
"execution_context": {
"terraform_version": "1.16.4",
"terraform_executable_sha256": "<installed executable SHA-256>",
"os_arch": "<uname -srm>",
"shell": "<recorded Bash build>",
"working_directory": "<canonical absolute repo path>",
"workspace": "default",
"fixture_commit": "<B Git commit>"
},
"reviewer_decision": "APPLY",
"supersession_status": "ACTIVE"
}A SHA-256 digest answers “are these bytes the bytes referenced by the manifest?” It does not answer “who created them?”, “were they reviewed competently?” or “does this organization still authorize them?” Those require independent policy evidence.
HashiCorp’s Running Terraform in Automation guidance explicitly discusses preserving a saved plan between planning and application and recommends connecting approval strongly enough that the apply step receives the correct plan rather than another outstanding artifact. That is precisely the identity problem the digest and approval record address.
Change the source to C without changing state
Create the full C variant:
terraform {
required_version = "= 1.16.4"
backend "local" {
path = "lab.tfstate"
}
}
resource "terraform_data" "release" {
input = "C"
}
output "release_revision" {
value = terraform_data.release.output
}Immediately after the B plan, hash the state file:
sha256sum lab.tfstate >../evidence/state-before-c.sha256Then change only source:
cp ../../../variants/C/main.tf main.tf
git add main.tf
git commit -m "fixture: release C"
git rev-parse HEAD >../evidence/fixture-c.commit
sha256sum main.tf >../evidence/source-c.sha256
sha256sum lab.tfstate >../evidence/state-after-c-edit.sha256Compare the two state digests. The canonical case requires them to be identical. There must be no intervening apply, no state migration, no direct state edit and no external system capable of changing this terraform_data resource.
This evidence is stronger than saying “we do not think state changed.” It records the actual bytes before and after the source-only transition.
Now independently change authorization. Do not derive authority from the eventual applied value.
The old B approval remains archived but becomes:
{
"authorized_source_revision": "<B Git commit>",
"authorized_source_digest_sha256": "<B main.tf SHA-256>",
"plan_digest_sha256": "<plan-b.tfplan SHA-256>",
"reviewer_decision": "HOLD",
"supersession_status": "SUPERSEDED_BY_C"
}C becomes the current release intent before there is an executable C artifact:
{
"schema": "terraform-plan-approval/v1",
"authorized_source_revision": "<C Git commit>",
"authorized_source_digest_sha256": "<C main.tf SHA-256>",
"plan_digest_sha256": null,
"supersedes_plan_digest_sha256": "<plan-b.tfplan SHA-256>",
"reviewer_decision": "REPLAN_REQUIRED",
"supersession_status": "ACTIVE_PENDING_PLAN"
}The null is intentional. A C plan does not yet exist, so inventing a C plan digest would be false evidence. The manifest still records which B plan it supersedes, and its non-APPLY decision makes it non-executable. When a fresh C plan exists and is reviewed, a new active manifest will contain that real plan digest.
At this point, four facts must be kept separate:
Fact | Value in canonical case |
Applied state | A |
Saved executable plan | B |
Current source | C |
Current authorized intent | C; fresh plan required |
Notice that the tree can be perfectly clean at C. Nothing about this failure requires a dirty working directory. Dirty-tree status is useful if policy forbids uncommitted source, but it is not Terraform’s definition of whether a saved plan remains executable.
The same reasoning prevents an opposite mistake: an older B checkout is not automatically unauthorized merely because C exists somewhere. In the separate B-still-approved control, B remains the declared authority. The independent manifest, not recency alone, makes the decision.
Apply B and reconcile the observed result
The negative case is the only place where the supersession gate is deliberately bypassed. Do this only inside the disposable fixture.
First capture the pre-apply facts independently:
terraform show -no-color \
>../evidence/before-old-b-apply.state.txt \
2>../evidence/before-old-b-apply.state.stderr
terraform show -json \
>../evidence/before-old-b-apply.state.json \
2>../evidence/before-old-b-apply.state-json.stderr
sha256sum main.tf \
>../evidence/before-old-b-apply.source-c.sha256
sha256sum ../artifacts/plan-b.tfplan \
>../evidence/before-old-b-apply.plan-b.sha256Then directly invoke the old artifact:
set +e
terraform apply \
-input=false \
-no-color \
../artifacts/plan-b.tfplan \
>../evidence/apply-old-b.stdout \
2>../evidence/apply-old-b.stderr
old_b_rc=$?
set -e
printf '%s\n' "$old_b_rc" \
>../evidence/apply-old-b.exitcodeThere is deliberately no tee pipeline. The saved exit code belongs to Terraform itself.
The documented-behavior prediction is that a compatible, still-valid plan-b.tfplan executes B even though main.tf currently contains C. HashiCorp states that supplying a saved plan selects saved-plan mode and executes the operations in that plan; it also states that the plan file already contains the final results of the planning decisions. The plan documentation adds that the saved file contains a copy of configuration and associated planned values.
That is not a claim that this article’s fixture has already produced B. The actual run must establish the result:
terraform show -no-color \
>../evidence/after-old-b-apply.state.txt \
2>../evidence/after-old-b-apply.state.stderr
terraform show -json \
>../evidence/after-old-b-apply.state.json \
2>../evidence/after-old-b-apply.state-json.stderr
terraform output -raw release_revision \
>../evidence/after-old-b-apply.output.txt \
2>../evidence/after-old-b-apply.output.stderr
sha256sum main.tf \
>../evidence/after-old-b-apply.source-c.sha256If the process returns zero and state/output says B while source remains C, record three conclusions separately:
Apply completed: supported by the Terraform process exit code and post-apply state.
Approved intent executed: false, because the independent authority had already superseded B with C.
Current configuration converged: false, because current source is C while managed state is B.
A green first statement does not make the other two green.
Now create a new C plan against the resulting state:
set +e
terraform plan \
-input=false \
-no-color \
-detailed-exitcode \
-out=../artifacts/plan-c-after-b.tfplan \
>../evidence/plan-c.stdout \
2>../evidence/plan-c.stderr
plan_c_rc=$?
set -e
printf '%s\n' "$plan_c_rc" \
>../evidence/plan-c.exitcodeThe predicted exit code is 2, meaning a successful non-empty plan, not failure. Inspect and preserve it independently:
terraform show -no-color ../artifacts/plan-c-after-b.tfplan \
>../evidence/plan-c.show.txt \
2>../evidence/plan-c.show.txt.stderr
terraform show -json ../artifacts/plan-c-after-b.tfplan \
>../evidence/plan-c.show.json \
2>../evidence/plan-c.show-json.stderr
sha256sum ../artifacts/plan-c-after-b.tfplan \
>../evidence/plan-c.plan.sha256Under the declared conditions, a B-to-C change is evidence of the remaining configuration difference. It is not evidence that Terraform failed to execute B, and it is not evidence of remote drift. terraform_data does not directly operate an external system, so this fixture intentionally removes remote infrastructure changes from the central question.
Run the comparison cases from separate roots. Never borrow a plan, state file or later green result from another root to “complete” a case.
Case | Source at apply | Approval | Plan | Expected policy decision | Documented prediction |
B still authorized | B | Active B | B | Apply | Saved B plan may execute; post-state B |
B superseded by C | C | B superseded; C requires re-plan | Old B | Hold/re-plan | Direct bypass may faithfully execute B, but release acceptance fails |
Fresh C review | C | Active C | Fresh C | Apply | Execute reviewed C artifact |
Missing/wrong digest | Any | Manifest does not identify supplied bytes | Mismatch | Hold before Terraform | Wrapper does not invoke Terraform |
The renewed-review control is important. Start from a clean A root, save B, edit to C without applying B, create a fresh C plan against A, review that exact C artifact and apply it through the gate. This shows that the repair is not “make Terraform accept B”; it is “produce and authorize an artifact matching current intent.”
The B-still-approved control prevents the opposite overreach. If source remains B, the B plan digest matches, context matches and the B manifest remains ACTIVE, the gate should permit B. That proves the policy is about identity and authority rather than mechanically rejecting anything called “old.”
The digest-negative control changes the manifest’s plan digest to a known-wrong value, such as 64 zeroes. The gate must reject before starting any Terraform process. The wrapper below writes a marker immediately before its first permissible Terraform invocation; absence of that marker is the negative-control evidence.
State compatibility is a separate axis. HashiCorp’s automation documentation notes that after one plan is applied, other plans created against the same prior state need recomputation, and its guidance recommends sequencing outstanding plans accordingly. The canonical experiment avoids intervening state writes between planning B and editing C specifically so that source/approval identity is the variable being tested.
Likewise, the local backend’s state locking, provider behavior, concurrent backend writers and remote drift are different questions. Do not create a fake “stale plan” demonstration by changing state serials or lineage. Manual corruption would test your corruption, not the source-to-plan identity problem.
The local result also does not imply that a production saved plan is a fresh observation of every remote object at execution time. Planning and applying remote resources involve provider and backend semantics outside this fixture.
Recovery from a successfully applied but superseded B is forward-moving:
terraform show -no-color > recovery-current-state.txt
terraform show -json > recovery-current-state.json
# Keep source at the actually intended target, for example C.
terraform plan \
-input=false \
-no-color \
-detailed-exitcode \
-out=recovery-c.tfplanReview that new plan against the actual B state, record its digest, obtain whatever approval is applicable and only then apply it. The intended target might be A, B or C; do not assume C merely because it is newer.
Never recommend copying an old terraform.tfstate over current state as a way to undo an applied configuration. That rewrites Terraform’s record; it does not itself reverse the real managed change. Recovery evidence begins with what Terraform currently records, selects an intended configuration, plans from that condition, and reviews the resulting forward transition.
A reviewer-readable evidence ledger can preserve the failed and repaired paths side by side. The concept complements broader observability and evidence ownership without turning this experiment into a monitoring tutorial.
Use columns such as:
Run | Source revision/digest | Plan digest | Authority | Pre-state | Command result | Post-state | Acceptance | Next action |
negative-old-B | C / C hash | B hash | B superseded | A | actual exit code | actual value | Pass/fail after evidence | Re-plan/hold/recover |
control-B | B / B hash | B hash | B active | A | actual | actual | Evaluate | None or hold |
control-C | C / C hash | C hash | C active | A | actual | actual | Evaluate | None or hold |
digest-reject | B / B hash | mismatch | B record malformed | A | wrapper code | unchanged | Expected hold | Correct manifest |
Do not populate “actual” cells until the corresponding run exists. Preserve failed controls rather than replacing them with repaired evidence. Save secrets nowhere in this synthetic fixture; in real workloads, plan and JSON retention must reflect HashiCorp’s warnings about sensitive content.
The release reviewer owns the authority decision. The state owner owns the condition against which a new plan is generated. The execution system owns proving exactly which approved bytes it invoked. These responsibilities fit within broader IaC operating practices, but the acceptance rule here remains narrow:
Apply when source, plan, context and active authority all agree.
Re-plan when authorized intent has changed but nothing divergent has yet been executed.
Hold when any required identity is absent, contradictory or unverifiable, even if some earlier command was green.
Recover after an already applied result no longer matches authorized intent.
The decisive evidence is not the last zero exit status. It is whether that status belongs to the exact configuration-to-plan-to-approval identity the release process authorized.
Build an apply gate that fails closed
The gate should validate inexpensive identity evidence before invoking Terraform, then validate Terraform-dependent context, then execute exactly one plan.
This is where general pipeline-as-code delivery practices become concrete: the pipeline is not merely an ordered list of Terraform commands. It is an enforcement boundary that connects reviewed source, immutable plan bytes and current authority.
A practical ordering is:
Read the independent manifest.
Hash the supplied plan.
Hash the declared source scope.
Check reviewer decision and supersession status.
Verify source revision and declared working directory.
Hash the Terraform executable and validate OS/architecture.
Only after those checks pass, invoke Terraform to verify build/workspace.
Apply exactly the already-approved plan.
Record the real Terraform exit code.
Inspect post-state separately.
The gate below intentionally checks the plan digest before starting Terraform. It writes a marker immediately before the first permitted Terraform invocation.
#!/usr/bin/env bash
# apply-gate.sh
set -u -o pipefail
MANIFEST="${1:-}"
PLAN="${2:-}"
PREFIX="${3:-}"
if [[ -z "$MANIFEST" || -z "$PLAN" || -z "$PREFIX" ]]; then
echo "usage: $0 MANIFEST PLAN EVIDENCE_PREFIX" >&2
exit 64
fi
for c in python3 git; do
command -v "$c" >/dev/null 2>&1 || {
echo "missing command: $c" >&2
exit 69
}
done
sha256_file() {
python3 - "$1" <<'PY'
import hashlib, pathlib, sys
p = pathlib.Path(sys.argv[1])
h = hashlib.sha256()
with p.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
print(h.hexdigest())
PY
}
jget() {
python3 - "$MANIFEST" "$1" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
obj = json.load(f)
for part in sys.argv[2].split("."):
obj = obj[part]
print("" if obj is None else obj)
PY
}
# No Terraform process has been invoked above this point.
expected_plan_sha="$(jget plan_digest_sha256)"
actual_plan_sha="$(sha256_file "$PLAN")"
if [[ -z "$expected_plan_sha" ||
"$actual_plan_sha" != "$expected_plan_sha" ]]; then
echo "HOLD: plan digest missing or mismatched" >&2
exit 41
fi
expected_source_sha="$(jget authorized_source_digest_sha256)"
actual_source_sha="$(sha256_file main.tf)"
if [[ "$actual_source_sha" != "$expected_source_sha" ]]; then
echo "HOLD: source digest mismatched" >&2
exit 42
fi
if [[ "$(jget reviewer_decision)" != "APPLY" ]]; then
echo "HOLD: reviewer decision is not APPLY" >&2
exit 43
fi
if [[ "$(jget supersession_status)" != "ACTIVE" ]]; then
echo "HOLD: approval is not active" >&2
exit 44
fi
if [[ "$(git rev-parse HEAD)" !=
"$(jget authorized_source_revision)" ]]; then
echo "HOLD: source revision mismatched" >&2
exit 45
fi
if [[ "$(pwd -P)" !=
"$(jget execution_context.working_directory)" ]]; then
echo "HOLD: working directory mismatched" >&2
exit 46
fi
tf_exe="$(
python3 - <<'PY'
import os, shutil
p = shutil.which("terraform")
print(os.path.realpath(p) if p else "")
PY
)"
if [[ -z "$tf_exe" ||
"$(sha256_file "$tf_exe")" !=
"$(jget execution_context.terraform_executable_sha256)" ]]; then
echo "HOLD: Terraform executable missing or digest mismatched" >&2
exit 47
fi
if [[ "$(uname -srm)" !=
"$(jget execution_context.os_arch)" ]]; then
echo "HOLD: OS/architecture mismatched" >&2
exit 48
fi
# From here onward, any Terraform invocation creates evidence.
printf '%s\n' "terraform invocation permitted" \
>"$PREFIX.terraform-invoked.marker"
tf_version="$(
terraform version -json |
python3 -c \
'import json,sys; print(json.load(sys.stdin)["terraform_version"])'
)"
if [[ "$tf_version" !=
"$(jget execution_context.terraform_version)" ]]; then
echo "HOLD: Terraform version mismatched" >&2
exit 49
fi
workspace="$(terraform workspace show)"
if [[ "$workspace" !=
"$(jget execution_context.workspace)" ]]; then
echo "HOLD: workspace mismatched" >&2
exit 50
fi
terraform apply \
-input=false \
-no-color \
"$PLAN" \
>"$PREFIX.apply.stdout" \
2>"$PREFIX.apply.stderr"
rc=$?
printf '%s\n' "$rc" >"$PREFIX.apply.exitcode"
exit "$rc"For the digest-negative test, copy an otherwise valid B manifest and replace only its plan hash:
python3 - \
approval/approval-b-active.json \
approval/approval-b-bad-digest.json <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
obj = json.load(f)
obj["plan_digest_sha256"] = "0" * 64
with open(sys.argv[2], "w", encoding="utf-8") as f:
json.dump(obj, f, indent=2, sort_keys=True)
f.write("\n")
PY
rm -f evidence/digest-reject.terraform-invoked.marker
set +e
./apply-gate.sh \
approval/approval-b-bad-digest.json \
artifacts/plan-b.tfplan \
evidence/digest-reject
wrapper_rc=$?
set -e
printf '%s\n' "$wrapper_rc" \
>evidence/digest-reject.wrapper.exitcode
test ! -e evidence/digest-reject.terraform-invoked.markerThe expected wrapper code is the gate’s own digest failure, and the marker must remain absent. That proves the wrapper rejected the reference before starting Terraform. It does not prove every possible executor is safe; it proves this control path behaved as designed.
Approval invalidation must also be explicit. A source change, input change, plan-byte change, Terraform executable change, incompatible platform/context change or reviewer supersession should require whatever renewed review the workflow declares. An older revision can still be intentionally authorized, but that must be a new, explicit authority decision, not an accident caused by finding an old file on disk.
The following command driver ties the lab cases together. It deliberately creates a fresh root for each case and never feeds one case’s state into another.
#!/usr/bin/env bash
# run-case.sh
set -u -o pipefail
TF_EXPECTED="1.16.4"
MODE="${1:-}"
RUN_ROOT="${2:-}"
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
case "$MODE" in
negative|control-b|control-c|digest-reject) ;;
)
echo "usage: $0 {negative|control-b|control-c|digest-reject} RUN_ROOT" >&2
exit 64
;;
esac
for c in terraform git python3; do
command -v "$c" >/dev/null 2>&1 || exit 69
done
if [[ -e "$RUN_ROOT" ]] &&
[[ -n "$(ls -A "$RUN_ROOT" 2>/dev/null)" ]]; then
echo "RUN_ROOT must be absent or empty" >&2
exit 73
fi
mkdir -p \
"$RUN_ROOT/repo" \
"$RUN_ROOT/evidence" \
"$RUN_ROOT/artifacts" \
"$RUN_ROOT/approval"
RUN_ROOT="$(cd "$RUN_ROOT" && pwd -P)"
REPO="$RUN_ROOT/repo"
E="$RUN_ROOT/evidence"
ART="$RUN_ROOT/artifacts"
APPROVAL="$RUN_ROOT/approval"
sha256_file() {
python3 - "$1" <<'PY'
import hashlib, pathlib, sys
p = pathlib.Path(sys.argv[1])
h = hashlib.sha256()
with p.open("rb") as f:
for chunk in iter(lambda: f.read(1024 1024), b""):
h.update(chunk)
print(h.hexdigest())
PY
}
tf_json="$(terraform version -json)"
tf_version="$(
python3 -c \
'import json,sys; print(json.load(sys.stdin)["terraform_version"])' \
<<<"$tf_json"
)"
[[ "$tf_version" == "$TF_EXPECTED" ]] || {
echo "expected Terraform $TF_EXPECTED, got $tf_version" >&2
exit 65
}
tf_exe="$(
python3 - <<'PY'
import os, shutil
print(os.path.realpath(shutil.which("terraform")))
PY
)"
tf_exe_sha="$(sha256_file "$tf_exe")"
printf '%s\n' "$tf_json" >"$E/terraform-version.json"
printf '%s\n' "$tf_exe" >"$E/terraform-executable.path"
printf '%s\n' "$tf_exe_sha" >"$E/terraform-executable.sha256"
uname -a >"$E/os-uname.txt"
printf '%s\n' "$BASH_VERSION" >"$E/shell.version"
[[ -r /etc/os-release ]] && cp /etc/os-release "$E/os-release.txt"
cd "$REPO"
git init -q
git config user.name "Terraform Saved Plan Lab"
git config user.email "[email protected]"
printf '.terraform/\nlab.tfstate\nlab.tfstate.*\n' >.gitignore
install_variant() {
cp "$BASE_DIR/variants/$1/main.tf" main.tf
git add main.tf .gitignore
git commit -q -m "fixture: release $1"
}
snapshot_state() {
label="$1"
terraform show -no-color \
>"$E/$label.state.txt" \
2>"$E/$label.state.txt.stderr"
terraform show -json \
>"$E/$label.state.json" \
2>"$E/$label.state.json.stderr"
terraform output -raw release_revision \
>"$E/$label.output.txt" \
2>"$E/$label.output.stderr"
printf '%s\n' "$?" >"$E/$label.output.exitcode"
}
plan_saved() {
label="$1"
plan="$2"
terraform plan \
-input=false \
-no-color \
-detailed-exitcode \
-out="$plan" \
>"$E/$label.stdout" \
2>"$E/$label.stderr"
rc=$?
printf '%s\n' "$rc" >"$E/$label.exitcode"
case "$rc" in
0|2) ;;
1) return 1 ;;
) return 1 ;;
esac
terraform show -no-color "$plan" \
>"$E/$label.show.txt" \
2>"$E/$label.show.txt.stderr"
terraform show -json "$plan" \
>"$E/$label.show.json" \
2>"$E/$label.show.json.stderr"
sha256_file "$plan" >"$E/$label.plan.sha256"
return "$rc"
}
make_manifest() {
outfile="$1"
source_revision="$2"
source_sha="$3"
plan_sha="$4"
decision="$5"
supersession="$6"
workspace="$(terraform workspace show)"
cwd="$(pwd -P)"
os_arch="$(uname -srm)"
python3 - \
"$outfile" "$source_revision" "$source_sha" "$plan_sha" \
"$decision" "$supersession" "$tf_version" "$tf_exe_sha" \
"$os_arch" "$BASH_VERSION" "$cwd" "$workspace" <<'PY'
import json, sys
(
outfile, revision, source_sha, plan_sha, decision, supersession,
tf_version, tf_exe_sha, os_arch, shell, cwd, workspace
) = sys.argv[1:]
obj = {
"schema": "terraform-plan-approval/v1",
"authorized_source_revision": revision,
"authorized_source_digest_sha256": source_sha,
"source_digest_scope": "main.tf bytes",
"plan_digest_sha256": plan_sha or None,
"execution_context": {
"terraform_version": tf_version,
"terraform_executable_sha256": tf_exe_sha,
"os_arch": os_arch,
"shell": shell,
"working_directory": cwd,
"workspace": workspace,
"fixture_commit": revision,
},
"reviewer_decision": decision,
"supersession_status": supersession,
}
with open(outfile, "w", encoding="utf-8") as f:
json.dump(obj, f, indent=2, sort_keys=True)
f.write("\n")
PY
}
install_variant A
a_commit="$(git rev-parse HEAD)"
terraform init -input=false -no-color \
>"$E/init.stdout" 2>"$E/init.stderr"
init_rc=$?
printf '%s\n' "$init_rc" >"$E/init.exitcode"
[[ "$init_rc" -eq 0 ]] || exit "$init_rc"
terraform workspace show >"$E/workspace.txt"
pwd -P >"$E/working-directory.txt"
printf '%s\n' "$a_commit" >"$E/fixture-a.commit"
terraform apply \
-input=false \
-auto-approve \
-no-color \
>"$E/apply-a.stdout" \
2>"$E/apply-a.stderr"
apply_a_rc=$?
printf '%s\n' "$apply_a_rc" >"$E/apply-a.exitcode"
[[ "$apply_a_rc" -eq 0 ]] || exit "$apply_a_rc"
snapshot_state after-a
install_variant B
b_commit="$(git rev-parse HEAD)"
b_source_sha="$(sha256_file main.tf)"
printf \
'{"expected_from":"A","expected_to":"B","source_revision":"%s","source_digest_sha256":"%s"}\n' \
"$b_commit" "$b_source_sha" \
>"$APPROVAL/expected-b-before-plan.json"
plan_b="$ART/plan-b.tfplan"
plan_saved plan-b "$plan_b"
plan_b_rc=$?
[[ "$plan_b_rc" -eq 2 ]] || {
echo "expected B plan to contain a change" >&2
exit 70
}
plan_b_sha="$(cat "$E/plan-b.plan.sha256")"
make_manifest \
"$APPROVAL/approval-b-active.json" \
"$b_commit" "$b_source_sha" "$plan_b_sha" \
APPLY ACTIVE
case "$MODE" in
control-b)
"$BASE_DIR/apply-gate.sh" \
"$APPROVAL/approval-b-active.json" \
"$plan_b" \
"$E/control-b"
gate_rc=$?
printf '%s\n' "$gate_rc" >"$E/control-b.wrapper.exitcode"
[[ "$gate_rc" -eq 0 ]] || exit "$gate_rc"
snapshot_state control-b-after-apply
;;
digest-reject)
python3 - \
"$APPROVAL/approval-b-active.json" \
"$APPROVAL/approval-b-bad-digest.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
obj = json.load(f)
obj["plan_digest_sha256"] = "0" 64
with open(sys.argv[2], "w", encoding="utf-8") as f:
json.dump(obj, f, indent=2, sort_keys=True)
f.write("\n")
PY
"$BASE_DIR/apply-gate.sh" \
"$APPROVAL/approval-b-bad-digest.json" \
"$plan_b" \
"$E/digest-reject"
reject_rc=$?
printf '%s\n' "$reject_rc" \
>"$E/digest-reject.wrapper.exitcode"
[[ ! -e "$E/digest-reject.terraform-invoked.marker" ]] || exit 71
;;
negative|control-c)
state_before_c="$(sha256_file lab.tfstate)"
install_variant C
c_commit="$(git rev-parse HEAD)"
c_source_sha="$(sha256_file main.tf)"
state_after_c="$(sha256_file lab.tfstate)"
printf '%s\n' "$state_before_c" >"$E/state-before-c.sha256"
printf '%s\n' "$state_after_c" >"$E/state-after-c-edit.sha256"
[[ "$state_before_c" == "$state_after_c" ]] || {
echo "state changed during source-only C edit" >&2
exit 72
}
make_manifest \
"$APPROVAL/authority-c-pending-plan.json" \
"$c_commit" "$c_source_sha" "" \
REPLAN_REQUIRED ACTIVE_PENDING_PLAN
make_manifest \
"$APPROVAL/approval-b-superseded.json" \
"$b_commit" "$b_source_sha" "$plan_b_sha" \
HOLD SUPERSEDED_BY_C
if [[ "$MODE" == "negative" ]]; then
snapshot_state before-old-b-apply
terraform apply \
-input=false \
-no-color \
"$plan_b" \
>"$E/apply-old-b.stdout" \
2>"$E/apply-old-b.stderr"
old_b_rc=$?
printf '%s\n' "$old_b_rc" >"$E/apply-old-b.exitcode"
snapshot_state after-old-b-apply
plan_c="$ART/plan-c-after-b.tfplan"
else
plan_c="$ART/plan-c-from-a.tfplan"
fi
plan_saved plan-c "$plan_c"
plan_c_rc=$?
[[ "$plan_c_rc" -eq 2 ]] || exit 74
plan_c_sha="$(cat "$E/plan-c.plan.sha256")"
make_manifest \
"$APPROVAL/approval-c-active.json" \
"$c_commit" "$c_source_sha" "$plan_c_sha" \
APPLY ACTIVE
if [[ "$MODE" == "control-c" ]]; then
"$BASE_DIR/apply-gate.sh" \
"$APPROVAL/approval-c-active.json" \
"$plan_c" \
"$E/control-c"
control_c_rc=$?
printf '%s\n' "$control_c_rc" \
>"$E/control-c.wrapper.exitcode"
[[ "$control_c_rc" -eq 0 ]] || exit "$control_c_rc"
snapshot_state control-c-after-apply
fi
;;
esacRun each case into an empty directory:
./run-case.sh negative runs/negative-old-b-after-c
./run-case.sh control-b runs/control-b-still-approved
./run-case.sh control-c runs/control-c-renewed-review
./run-case.sh digest-reject runs/control-digest-rejectOne caveat is deliberate: the driver constructs synthetic lab approval records to make the fixture reproducible. In an operational release system, the executor should not be able to manufacture its own independent approval. The real manifest should come from the organization’s review authority and be made immutable under that workflow before execution.
Likewise, this gate requires an exact working-directory match. That is a laboratory policy, not a universal Terraform principle. HashiCorp documents additional constraints for applying plans on different machines and paths. A workflow may permit a plan created elsewhere only when its orchestration design explicitly preserves the required compatible environment and its authorization model says that context is valid. “There is a newer checkout here” and “this artifact is unauthorized” are not logically equivalent statements.
Build IaC delivery foundations with Refonte Learning
The practical lesson from this fixture is not that saved Terraform plans are unsafe. Saved plans are useful precisely because review and execution can be separated. The reliability problem appears when an organization loses the identity link between reviewed source, executable plan, execution context and current authority.
A disciplined Terraform release process therefore needs engineers who can reason beyond whether terraform apply printed success. They need to distinguish planning from execution, state from source, artifact integrity from authorization, and recovery from state-file rewriting.
Refonte Learning’s DevOps Engineering program describes a three-month programme with an expected commitment of 12–14 hours per week. Its published curriculum includes Git/GitHub, CI/CD, Docker and Kubernetes, Infrastructure as Code with Terraform, cloud platforms, and monitoring/logging; the page also describes virtual-internship opportunities.
Those curriculum areas are relevant to the release discipline tested here: Terraform artifacts need version-controlled source, approval-aware delivery automation and evidence that survives beyond the last successful command. The programme page also states an admission prerequisite of working toward a bachelor’s or higher-level degree and describes successful completion as leading to a Training Certificate and a Certificate of Internship.
The engineering standard remains the same regardless of training context: never accept “Terraform returned zero” as a complete release claim. Accept the change only when the evidence demonstrates that the plan which ran was the plan still authorized to run.
