fix: make Terraform fixture generation predictable (#29481)

`make gen` could regenerate every Terraform fixture during unrelated
work because the installed CLI differed from `version.txt`, or because
the provider-version check failed.

`make gen` now compares the generator, fixture sources, provider lock,
and mise’s Terraform pin against a committed record. Changed inputs
trigger regeneration; a different installed executable or checkout
timestamps do not. Terraform runs in a temporary directory, and outputs
are copied back only after generation succeeds. Shared CI checks the
record and conversion behavior without regenerating Terraform fixtures.

Fixes CODAGT-1082
This commit is contained in:
Mathias Fredriksson
2026-09-21 19:49:33 +03:00
committed by GitHub
parent a970c6c92e
commit 297a55051b
6 changed files with 194 additions and 68 deletions
+5 -1
View File
@@ -444,7 +444,7 @@ jobs:
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm terraform protoc protoc-gen-go"
install-args: "go node pnpm protoc protoc-gen-go"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
@@ -458,6 +458,10 @@ jobs:
- name: Start PostgreSQL container
run: make test-postgres-docker
# Reject an outdated input record before make gen could invoke Terraform.
- name: Check Terraform fixture record
run: ./provisioner/terraform/testdata/generate.sh --check
- name: make gen
timeout-minutes: 8
run: |
+16 -13
View File
@@ -1018,7 +1018,7 @@ GEN_FILES := \
docs/admin/setup/configuration-reference.md \
coderd/apidoc/swagger.json \
docs/manifest.json \
provisioner/terraform/testdata/version \
provisioner/terraform/testdata/generation.sha1 \
scripts/metricsdocgen/generated_metrics \
site/e2e/provisionerGenerated.ts \
examples/examples.gen.json \
@@ -1465,21 +1465,24 @@ coderd/notifications/.gen-golden: $(wildcard coderd/notifications/testdata/*/*.g
TZ=UTC go test ./coderd/notifications -run="Test.*Golden$$" -update
touch "$@"
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(wildcard provisioner/terraform/testdata/*/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
# Wait for fixture generation before reading its outputs under make -j.
provisioner/terraform/testdata/.gen-golden: provisioner/terraform/testdata/generation.sha1 $(wildcard provisioner/terraform/testdata/resources/*/*.tfplan.* provisioner/terraform/testdata/resources/*/*.tfstate.*) $(wildcard provisioner/terraform/testdata/*/*.golden) $(wildcard provisioner/terraform/testdata/*/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
TZ=UTC go test ./provisioner/terraform -run="Test.*Golden$$" -update
touch "$@"
provisioner/terraform/testdata/version:
@tf_match=true; \
if [[ "$$(cat provisioner/terraform/testdata/version.txt)" != \
"$$(terraform version -json | jq -r '.terraform_version')" ]]; then \
tf_match=false; \
fi; \
if ! $$tf_match || \
! ./provisioner/terraform/testdata/generate.sh --check; then \
./provisioner/terraform/testdata/generate.sh; \
fi
.PHONY: provisioner/terraform/testdata/version
# Terraform reads ~/.terraformrc unless TF_CLI_CONFIG_FILE selects another file.
# After rebuilding a local provider or changing its override, regenerate with:
# ./provisioner/terraform/testdata/generate.sh
provisioner/terraform/testdata/generation.sha1: FORCE
@./provisioner/terraform/testdata/generate.sh --if-needed
FORCE:
.PHONY: FORCE
# pre-commit runs gen and fmt concurrently; formatting must finish before hashing.
ifneq ($(filter fmt,$(MAKECMDGOALS)),)
provisioner/terraform/testdata/generation.sha1: | fmt/terraform fmt/shfmt
endif
update-terraform-testdata:
./provisioner/terraform/testdata/generate.sh --upgrade
+2 -2
View File
@@ -189,8 +189,7 @@
vendorHash = "sha256-OuQWmZmofdJKq1hvk43RPkILQwAuFzqhmB22Xf6Z3lA=";
};
# Pin to provisioner/terraform/testdata/version.txt for deterministic
# `make gen` across platforms.
# Keep this pin aligned with mise.toml when updating Terraform.
terraform_1_15_5 =
let
releases = {
@@ -314,6 +313,7 @@
proto_gen_go_1_30
protobuf_23_4
ripgrep
rsync
shellcheck
(pinnedPkgs.shfmt)
# sqlc
+170 -51
View File
@@ -7,6 +7,58 @@ scriptdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$scriptdir/resources"
canonical_lock="$(pwd)/.terraform.lock.hcl"
manifest="$scriptdir/generation.sha1"
# kubernetes-metadata requires live infrastructure. duplicate-env-keys has
# repeated JSON keys whose UUIDs cannot be preserved by minimize_diff.
skip_module() {
case "$1" in
kubernetes-metadata | duplicate-env-keys) return 0 ;;
*) return 1 ;;
esac
}
terraform_version() {
local version
version="$(awk -F '"' '
/^[[:space:]]*\[/ { tools = ($0 ~ /^[[:space:]]*\[tools\][[:space:]]*(#.*)?$/) }
tools && /^[[:space:]]*terraform[[:space:]]*=[[:space:]]*"/ { print $2 }
' "$scriptdir/../../../mise.toml")" || return
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: mise.toml must pin tools.terraform to a release version." >&2
return 1
fi
printf '%s\n' "$version"
}
# Content hashes avoid regeneration after checkout or a local CLI change.
fingerprint() {
local file module i
local lockfile="${1:-.terraform.lock.hcl}"
local version
version="$(terraform_version)" || return
local -a files=(../generate.sh .terraform.lock.hcl)
find . -type d \( -name .terraform -o -name .coder \) -prune -o \( -type f -o -type l \) -print0 |
LC_ALL=C sort -z | {
while IFS= read -r -d '' file; do
module="${file#./}"
module="${module%%/*}"
if skip_module "$module"; then
continue
fi
case "$file" in
*.tfplan.json | *.tfstate.json | *.tfplan.dot | *.tfstate.dot | ./.terraform.lock.hcl | */.terraform.lock.hcl | *.tfstate | *.tfstate.* | *.tfplan | *.golden | *.md | */.gitignore) continue ;;
esac
files+=("$file")
done
printf 'terraform\0%s\0' "$version"
printf '%s\0' "${files[@]}"
for i in "${!files[@]}"; do
[[ "${files[i]}" != .terraform.lock.hcl ]] || files[i]="$lockfile"
done
git hash-object --no-filters -- "${files[@]}"
} | git hash-object --stdin
}
# These environment variables influence the coder provider.
for v in $(env | grep -E '^CODER_' | cut -d= -f1); do
@@ -14,11 +66,16 @@ for v in $(env | grep -E '^CODER_' | cut -d= -f1); do
done
generate() {
local name="$1"
local name="$1" ret
echo "=== BEGIN: $name"
if ((upgrade)); then
terraform init -upgrade
elif [[ -d "$scriptdir/resources/$name/.terraform/providers" ]]; then
# An installed provider directory avoids registry version lookups.
if ! terraform init -plugin-dir="$scriptdir/resources/$name/.terraform/providers"; then
rm -rf .terraform/providers && terraform init
fi
else
terraform init
fi &&
@@ -32,16 +89,24 @@ generate() {
terraform graph -type=plan >"$name".tfstate.dot
ret=$?
echo "=== END: $name"
if [[ $ret -ne 0 ]]; then
return $ret
fi
return "$ret"
}
minimize_diff() {
local name="$1"
local f diff status line key value
for f in *.tf*.json; do
[[ -f "$scriptdir/resources/$name/$f" ]] || continue
if diff="$(git diff --no-index -- "$scriptdir/resources/$name/$f" "$f")"; then
continue
else
status=$?
((status == 1)) || return "$status"
fi
declare -A deleted=()
declare -a sed_args=()
while read -r line; do
while IFS= read -r line; do
[[ $line =~ \"(terraform_version|id|agent_id|subagent_id|resource_id|token|random|timestamp)\": ]] || continue
# Deleted line (previous value).
if [[ $line = -\ * ]]; then
key="${line#*\"}"
@@ -71,11 +136,7 @@ minimize_diff() {
sed -i'' "${sed_args[@]}" "$f"
fi
fi
done < <(
# Filter out known keys with autogenerated values.
git diff -- "$f" |
grep -E "\"(terraform_version|id|agent_id|subagent_id|resource_id|token|random|timestamp)\":"
)
done <<<"$diff"
done
}
@@ -89,24 +150,14 @@ extract_provider_version() {
}
run() {
d="$1"
local d="$1" name out
cd "$d"
name=$(basename "$(pwd)")
name="${PWD##*/}"
toskip=(
# This needs care to update correctly.
"kubernetes-metadata"
# Multiple resources with duplicate JSON key names (id, agent_id)
# cause minimize_diff() to scramble UUIDs. Hand-crafted fixture.
"duplicate-env-keys"
)
for skip in "${toskip[@]}"; do
if [[ $name == "$skip" ]]; then
echo "== Skipping: $name"
touch "$name.tfplan.json" "$name.tfplan.dot" "$name.tfstate.json" "$name.tfstate.dot"
return 0
fi
done
if skip_module "$name"; then
echo "== Skipping hand-maintained fixture: $name"
return 0
fi
echo "== Generating test data for: $name"
if ! out="$(generate "$name" 2>&1)"; then
@@ -116,14 +167,20 @@ run() {
fi
if ((minimize)); then
echo "== Minimizing diffs for: $name"
minimize_diff
minimize_diff "$name"
fi
mkdir -p "$workdir/result/resources/$name"
mv "$name".tf{plan,state}.{json,dot} "$workdir/result/resources/$name/"
if [[ -d .terraform/providers ]]; then
mkdir -p "$workdir/providers/resources/$name"
find .terraform/providers -type f -print0 | rsync -r --from0 --files-from=- ./ "$workdir/providers/resources/$name/"
fi
echo "== Done generating test data for: $name"
exit 0
return 0
}
if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then
echo "Usage: $0 [--upgrade] [--check] [--no-minimize] [module1 module2 ...]"
echo "Usage: $0 [--upgrade] [--check] [--if-needed] [--no-minimize] [module1 module2 ...]"
exit 0
fi
@@ -137,17 +194,34 @@ if [[ " $* " == *" --upgrade "* ]]; then
upgrade=1
fi
# Verify that the canonical lockfile matches provider-version.txt.
if_needed=0
if [[ " $* " == *" --if-needed "* ]]; then
if_needed=1
fi
check=0
if [[ " $* " == *" --check "* ]]; then
check=1
fi
inputs="$(fingerprint)"
if ((check)); then
expected="$(<"$scriptdir/provider-version.txt")"
actual="$(extract_provider_version "$canonical_lock")"
if [[ "$expected" == "$actual" ]]; then
exit 0
else
echo "ERROR: provider-version.txt ($expected) does not match lockfile ($actual)"
if [[ "$expected" != "$actual" ]]; then
echo "ERROR: provider-version.txt ($expected) does not match lockfile ($actual)" >&2
exit 1
fi
fi
if ((check || if_needed)); then
if [[ -f "$manifest" && "$(cat "$manifest")" == "$inputs" ]]; then
exit 0
fi
if ((check)); then
echo "Terraform fixture inputs changed; run make gen." >&2
exit 1
fi
echo "== Terraform fixture inputs changed; regenerating"
fi
# Committed testdata encodes linux/amd64 values from coder_provisioner.
# Regenerating elsewhere bakes in the host OS/arch.
@@ -160,32 +234,63 @@ if [[ "$(uname)" != "Linux" ]]; then
exit 0
fi
export CHECKPOINT_DISABLE=1
# Filter flags from positional args to get directory names.
declare -a dirs=()
for arg in "$@"; do
case "$arg" in
--upgrade | --no-minimize | --check | --help | -h) ;;
*) dirs+=("$arg") ;;
--upgrade | --no-minimize | --check | --if-needed | --help | -h) ;;
*)
module="$(cd "$arg" && pwd)"
case "$module" in
"$scriptdir/resources/"*) dirs+=("${module#"$scriptdir/resources/"}") ;;
*)
echo "ERROR: module must be within $scriptdir/resources: $arg" >&2
exit 1
;;
esac
;;
esac
done
# Seed each resource subdirectory with the canonical lockfile.
# Keep Terraform's working files and incomplete outputs out of the checkout.
workdir="$(mktemp -d "$scriptdir/.generate.XXXXXX")"
cleanup() {
trap '' HUP INT TERM
wait || true
rm -rf "$workdir"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
mkdir -p "$workdir/result/resources" "$workdir/providers"
(
cd "$scriptdir"
tar --exclude='.terraform' --exclude='.coder' --exclude='*.tfstate' --exclude='*.tfstate.*' \
--exclude='*.tfplan' -cf - resources generate.sh
) | tar -xf - -C "$workdir"
cd "$workdir/resources"
canonical_lock="$workdir/resources/.terraform.lock.hcl"
copied_inputs="$(fingerprint)"
if [[ "$copied_inputs" != "$inputs" ]]; then
echo "ERROR: fixture inputs changed while copying; rerun generation." >&2
exit 1
fi
full=0
if [[ ${#dirs[@]} -eq 0 ]]; then
full=1
dirs=(*/)
fi
for d in */; do
cp "$canonical_lock" "$d/.terraform.lock.hcl"
done
declare -a jobs=()
if [[ ${#dirs[@]} -gt 0 ]]; then
for d in "${dirs[@]}"; do
run "$d" &
jobs+=($!)
done
else
for d in */; do
run "$d" &
jobs+=($!)
done
fi
for d in "${dirs[@]}"; do
run "$d" &
jobs+=($!)
done
err=0
for job in "${jobs[@]}"; do
@@ -198,6 +303,7 @@ if [[ $err -ne 0 ]]; then
exit 1
fi
recorded_inputs="$inputs"
# After upgrade, promote the lockfile from a representative directory
# back to the canonical location and record the provider version.
if ((upgrade)); then
@@ -215,10 +321,23 @@ if ((upgrade)); then
fi
if [[ -n "$src" ]]; then
cp "$src" "$canonical_lock"
cp "$canonical_lock" "$workdir/result/resources/.terraform.lock.hcl"
recorded_inputs="$(cd "$scriptdir/resources" && fingerprint "$canonical_lock")"
version="$(extract_provider_version "$canonical_lock")"
echo "$version" >"$scriptdir/provider-version.txt"
echo "$version" >"$workdir/result/provider-version.txt"
echo "== Updated canonical lockfile and provider-version.txt (coder provider $version)"
fi
fi
terraform version -json | jq -r '.terraform_version' >../version.txt
current_inputs="$(cd "$scriptdir/resources" && fingerprint)"
if [[ "$current_inputs" != "$inputs" ]]; then
echo "ERROR: fixture inputs changed during generation; rerun generation." >&2
exit 1
fi
rsync -rlt --ignore-existing "$workdir/providers/" "$scriptdir/"
rsync -rc --delay-updates "$workdir/result/" "$scriptdir/"
if ((full)); then
# Record success only after all outputs have been copied.
printf '%s\n' "$recorded_inputs" >"$workdir/generation.sha1"
rsync -c "$workdir/generation.sha1" "$scriptdir/"
fi
+1
View File
@@ -0,0 +1 @@
85e724a1f4c6fd4ace53208053534475f07d9949
-1
View File
@@ -1 +0,0 @@
1.16.2