diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 31c1e2193f..e529064111 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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: | diff --git a/Makefile b/Makefile index d5b77288c5..e4431de743 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/flake.nix b/flake.nix index a01a3216e4..a4502e94e2 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/provisioner/terraform/testdata/generate.sh b/provisioner/terraform/testdata/generate.sh index 23fee18813..6d0040835a 100755 --- a/provisioner/terraform/testdata/generate.sh +++ b/provisioner/terraform/testdata/generate.sh @@ -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 diff --git a/provisioner/terraform/testdata/generation.sha1 b/provisioner/terraform/testdata/generation.sha1 new file mode 100644 index 0000000000..507c0c161e --- /dev/null +++ b/provisioner/terraform/testdata/generation.sha1 @@ -0,0 +1 @@ +85e724a1f4c6fd4ace53208053534475f07d9949 diff --git a/provisioner/terraform/testdata/version.txt b/provisioner/terraform/testdata/version.txt deleted file mode 100644 index 4a02d2c317..0000000000 --- a/provisioner/terraform/testdata/version.txt +++ /dev/null @@ -1 +0,0 @@ -1.16.2