diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml new file mode 100644 index 000000000..71ec6fc4b --- /dev/null +++ b/.github/workflows/e2e-tests.yaml @@ -0,0 +1,55 @@ +name: E2E hook tests + +on: + merge_group: + pull_request: + push: + branches-ignore: + - dependabot/** # Dependabot always creates PRs + - renovate/** # Our Renovate setup always creates PRs + - gh-readonly-queue/** # Temporary merge queue-related GH-made branches + - pre-commit-ci-update-config # pre-commit.ci always creates a PR + +permissions: + contents: read + +concurrency: + group: >- + ${{ github.workflow }}-${{ github.ref_type }}-${{ + github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + e2e: + name: Run e2e hook tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + # The published image bundles every tool the hooks need (terraform, + # hcledit, pre-commit, git, ...). Hook and test code come from the checkout + # below, so the image only needs to provide tooling, not be in sync. + # Pinned by digest for reproducibility/supply-chain; bump via Renovate. + env: + # renovate: datasource=docker depName=ghcr.io/antonbabenko/pre-commit-terraform + # yamllint disable-line rule:line-length + IMAGE: ghcr.io/antonbabenko/pre-commit-terraform:latest@sha256:4ef4b8323b27fc263535ad88c9d2f20488fcb3b520258e5e7f0553ed5f6692b5 + + steps: + - name: Check out src from Git + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Pull the multi-tool image + run: docker pull "${IMAGE}" + + # Checkout happens on the host (needs Node for the JS action); the tests + # run inside the image, which the alpine-based image can't host directly. + - name: Run e2e tests inside the image + run: >- + docker run --rm + --volume "${GITHUB_WORKSPACE}:/lint" + --workdir /lint + --entrypoint bash + "${IMAGE}" + tests/e2e/run_e2e_tests.sh diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 000000000..cf532ee2e --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,88 @@ +# End-to-end hook tests + +Behavioral tests that run the hooks the same way a user would — through +`pre-commit` against real fixture files — and compare the result against a +committed "golden" output. See [issue #823][issue-823]. + +## Layout + +```text +tests/e2e/ + run_e2e_tests.sh # the runner + cases/// + .pre-commit-config.yaml # repo:local config; __PCT_REPO__ -> repo root + input/ # working tree the hook runs against + expected/ # working tree as it should look AFTER the hook + expected_returncode # optional, default 0 (fixing hooks exit 1) + requires # optional, one CLI tool per line; SKIP if missing +``` + +A case passes when the `pre-commit run` exit code matches `expected_returncode` +**and** the resulting working tree is byte-identical to `expected/`. + +Note: hooks that modify tracked files in place (e.g. `terraform_fmt`) make +`pre-commit` exit `1` ("files were modified by this hook"), so those cases set +`expected_returncode` to `1`. Hooks that only generate new files (e.g. +`terraform_wrapper_module_for_each`) leave tracked files untouched and exit `0`. + +## Prerequisites + +The runner itself needs `pre-commit` and `git`. Each case additionally needs +the tools its hook calls; a case is **skipped** (not failed) when a tool listed +in its `requires` file is missing from `PATH`. + +Current cases need: + +| Tool | Used by | +| --- | --- | +| `pre-commit`, `git` | the runner (always) | +| `terraform` | `terraform_fmt` | +| `hcledit` | `terraform_wrapper_module_for_each` | + +Install locally: + +```bash +# macOS (Homebrew) +brew install pre-commit terraform minamijoyo/hcledit/hcledit + +# Linux: pre-commit via pip, terraform via HashiCorp's repo, and hcledit: +curl -L "$(curl -s https://api.github.com/repos/minamijoyo/hcledit/releases/latest \ + | grep -o -E -m 1 "https://.+?_linux_amd64.tar.gz")" > hcledit.tar.gz \ + && tar -xzf hcledit.tar.gz hcledit && rm hcledit.tar.gz && sudo mv hcledit /usr/bin/ +``` + +See the repo [README "How to install"](../../README.md#how-to-install) for the +full tool list and other install methods. + +Or skip local installs entirely and use the project image, which bundles every +tool (this is what CI does) — see [Running](#running) below. + +## Running + +Natively: + +```bash +bash tests/e2e/run_e2e_tests.sh +``` + +Inside the project image (matches CI — bundles every tool, no local installs): + +```bash +docker build -t pct:e2e --build-arg INSTALL_ALL=true . +docker run --rm -v "$PWD:/lint" -w /lint --entrypoint bash pct:e2e \ + tests/e2e/run_e2e_tests.sh +``` + +## Adding a case + +1. Create `cases///`. +2. Add `input/` (the fixture) and a `.pre-commit-config.yaml` wiring the hook as + a `repo: local` hook with `entry: __PCT_REPO__/hooks/.sh`. +3. Generate `expected/`: run the hook once against a copy of `input/` and commit + the resulting tree. Set `expected_returncode` if the hook modifies files. +4. Add a `requires` file listing any non-trivial CLI tools the hook needs. + +The runner auto-discovers any `cases///` dir — no runner +changes are needed to cover a new hook. + +[issue-823]: https://github.com/antonbabenko/pre-commit-terraform/issues/823 diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/.pre-commit-config.yaml b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/.pre-commit-config.yaml new file mode 100644 index 000000000..1a62e256e --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: +- repo: local + hooks: + - id: terraform_fmt + name: terraform_fmt + language: script + entry: __PCT_REPO__/hooks/terraform_fmt.sh + files: \.(tf|tofu)$ diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected/main.tf b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected/main.tf new file mode 100644 index 000000000..8eeaf9f08 --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected/main.tf @@ -0,0 +1,8 @@ +variable "name" { + type = string + default = "demo" +} + +output "id" { + value = var.name +} diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected_returncode b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected_returncode new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/expected_returncode @@ -0,0 +1 @@ +1 diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/input/main.tf b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/input/main.tf new file mode 100644 index 000000000..f7709b89b --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/input/main.tf @@ -0,0 +1,8 @@ +variable "name" { +type=string + default = "demo" +} + +output "id" { + value = var.name +} diff --git a/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/requires b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/requires new file mode 100644 index 000000000..5ac7c5753 --- /dev/null +++ b/tests/e2e/cases/terraform_fmt/reformats-misaligned-hcl/requires @@ -0,0 +1 @@ +terraform diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/.pre-commit-config.yaml b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/.pre-commit-config.yaml new file mode 100644 index 000000000..fd9642611 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: +- repo: local + hooks: + - id: terraform_wrapper_module_for_each + name: terraform_wrapper_module_for_each + language: script + entry: __PCT_REPO__/hooks/terraform_wrapper_module_for_each.sh + files: \.(tf|tofu)$ + args: + - --args=--module-repo-org=terraform-aws-modules + - --args=--module-repo-shortname=s3-bucket + - --args=--module-repo-provider=aws diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/main.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/main.tf new file mode 100644 index 000000000..6609005e8 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/main.tf @@ -0,0 +1,7 @@ +variable "name" { + type = string +} + +output "id" { + value = "x" +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/versions.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/versions.tf new file mode 100644 index 000000000..dda48f783 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.3" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.27" + } + } +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/README.md b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/README.md new file mode 100644 index 000000000..4218b5b94 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/README.md @@ -0,0 +1,100 @@ +# Wrapper for the root module + +The configuration in this directory contains an implementation of a single module wrapper pattern, which allows managing several copies of a module in places where using the native Terraform 0.13+ `for_each` feature is not feasible (e.g., with Terragrunt). + +You may want to use a single Terragrunt configuration file to manage multiple resources without duplicating `terragrunt.hcl` files for each copy of the same module. + +This wrapper does not implement any extra functionality. + +## Usage with Terragrunt + +`terragrunt.hcl`: + +```hcl +terraform { + source = "tfr:///terraform-aws-modules/s3-bucket/aws//wrappers" + # Alternative source: + # source = "git::git@github.com:terraform-aws-modules/terraform-aws-s3-bucket.git//wrappers?ref=master" +} + +inputs = { + defaults = { # Default values + create = true + tags = { + Terraform = "true" + Environment = "dev" + } + } + + items = { + my-item = { + # omitted... can be any argument supported by the module + } + my-second-item = { + # omitted... can be any argument supported by the module + } + # omitted... + } +} +``` + +## Usage with Terraform + +```hcl +module "wrapper" { + source = "terraform-aws-modules/s3-bucket/aws//wrappers" + + defaults = { # Default values + create = true + tags = { + Terraform = "true" + Environment = "dev" + } + } + + items = { + my-item = { + # omitted... can be any argument supported by the module + } + my-second-item = { + # omitted... can be any argument supported by the module + } + # omitted... + } +} +``` + +## Example: Manage multiple S3 buckets in one Terragrunt layer + +`eu-west-1/s3-buckets/terragrunt.hcl`: + +```hcl +terraform { + source = "tfr:///terraform-aws-modules/s3-bucket/aws//wrappers" + # Alternative source: + # source = "git::git@github.com:terraform-aws-modules/terraform-aws-s3-bucket.git//wrappers?ref=master" +} + +inputs = { + defaults = { + force_destroy = true + + attach_elb_log_delivery_policy = true + attach_lb_log_delivery_policy = true + attach_deny_insecure_transport_policy = true + attach_require_latest_tls_policy = true + } + + items = { + bucket1 = { + bucket = "my-random-bucket-1" + } + bucket2 = { + bucket = "my-random-bucket-2" + tags = { + Secure = "probably" + } + } + } +} +``` diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/main.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/main.tf new file mode 100644 index 000000000..caca9f4fb --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/main.tf @@ -0,0 +1,7 @@ +module "wrapper" { + source = "../" + + for_each = var.items + + name = try(each.value.name, var.defaults.name) +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/outputs.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/outputs.tf new file mode 100644 index 000000000..ec6da5f4a --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/outputs.tf @@ -0,0 +1,5 @@ +output "wrapper" { + description = "Map of outputs of a wrapper." + value = module.wrapper + # sensitive = false # No sensitive module output found +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/variables.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/variables.tf new file mode 100644 index 000000000..a6ea0962c --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/variables.tf @@ -0,0 +1,11 @@ +variable "defaults" { + description = "Map of default values which will be used for each item." + type = any + default = {} +} + +variable "items" { + description = "Maps of items to create a wrapper from. Values are passed through to the module." + type = any + default = {} +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/versions.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/versions.tf new file mode 100644 index 000000000..dda48f783 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/expected/wrappers/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.3" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.27" + } + } +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/main.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/main.tf new file mode 100644 index 000000000..6609005e8 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/main.tf @@ -0,0 +1,7 @@ +variable "name" { + type = string +} + +output "id" { + value = "x" +} diff --git a/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/versions.tf b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/versions.tf new file mode 100644 index 000000000..dda48f783 --- /dev/null +++ b/tests/e2e/cases/terraform_wrapper_module_for_each/generates-wrapper-for-root-module/input/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.3" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.27" + } + } +} diff --git a/tests/e2e/run_e2e_tests.sh b/tests/e2e/run_e2e_tests.sh new file mode 100755 index 000000000..060d06c3d --- /dev/null +++ b/tests/e2e/run_e2e_tests.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# End-to-end tests for the hooks in this repo (see GH issue #823). +# +# Each test case lives in `tests/e2e/cases///` and contains: +# .pre-commit-config.yaml - `repo: local` config; `__PCT_REPO__` is replaced +# with the repo root so `entry` resolves. +# input/ - working tree the hook runs against. +# expected/ - the working tree as it should look AFTER the hook +# ran (input files + any generated/modified files). +# expected_returncode - optional; expected `pre-commit run` exit code (default 0). +# requires - optional; one CLI tool per line. Case is SKIPPED +# if any is missing from PATH. +# +# A case passes when the `pre-commit run` exit code matches `expected_returncode` +# AND the resulting working tree is byte-identical to `expected/`. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +readonly SCRIPT_DIR +readonly CASES_DIR="$SCRIPT_DIR/cases" + +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" +readonly REPO_ROOT + +# Colors (disabled when not a TTY). +if [[ -t 1 ]]; then + C_RED=$'\033[31m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m' C_RESET=$'\033[0m' +else + C_RED='' C_GREEN='' C_YELLOW='' C_RESET='' +fi +readonly C_RED C_GREEN C_YELLOW C_RESET + +declare -i passed=0 failed=0 skipped=0 +declare -a summary=() + +# Run a single case dir. Returns non-zero on failure. +function run_case { + local case_dir="$1" + local case_name hook_id test_id + case_name="$(basename "$case_dir")" + hook_id="$(basename "$(dirname "$case_dir")")" + test_id="${hook_id}/${case_name}" + + # Skip when a required tool is absent. + if [[ -f "$case_dir/requires" ]]; then + local tool + while read -r tool || [[ -n $tool ]]; do + [[ -z $tool || $tool == \#* ]] && continue + if ! command -v "$tool" > /dev/null 2>&1; then + summary+=("${C_YELLOW}SKIP${C_RESET} ${test_id} (missing: ${tool})") + skipped+=1 + return 0 + fi + done < "$case_dir/requires" + fi + + local work config log + work="$(mktemp -d)" + config="$(mktemp)" + log="$(mktemp)" + + # Materialize the input working tree as a git repo (hooks and pre-commit + # both expect one; the wrapper hook calls `git rev-parse --show-toplevel`). + cp -R "$case_dir/input/." "$work" + git -C "$work" init -q + git -C "$work" add -A + git -C "$work" \ + -c user.email=e2e@example.invalid -c user.name='e2e' \ + commit -qm 'e2e fixture' --no-verify + + # Render the per-case config, pointing `entry` at this checkout. + sed "s|__PCT_REPO__|${REPO_ROOT}|g" \ + "$case_dir/.pre-commit-config.yaml" > "$config" + + local actual_rc=0 + ( + cd "$work" + pre-commit run --config "$config" --all-files + ) &> "$log" || actual_rc=$? + + local expected_rc=0 + [[ -f "$case_dir/expected_returncode" ]] && + expected_rc="$(< "$case_dir/expected_returncode")" + + # Drop the throwaway git dir so the tree compare only sees fixture output. + # `git diff --no-index` is used instead of `diff -r` because the project + # image ships BusyBox `diff`, which lacks `--exclude` and `-u`. + rm -rf "$work/.git" + + local ok="true" reason='' + if [[ $actual_rc -ne $expected_rc ]]; then + ok="false" + reason="exit code ${actual_rc}, expected ${expected_rc}" + elif ! git --no-pager diff --no-index --exit-code \ + "$case_dir/expected" "$work" &> "$log.diff"; then + ok="false" + reason='output differs from expected' + fi + + local rc=0 + if [[ $ok == "true" ]]; then + summary+=("${C_GREEN}PASS${C_RESET} ${test_id}") + passed+=1 + else + summary+=("${C_RED}FAIL${C_RESET} ${test_id} (${reason})") + failed+=1 + echo "${C_RED}--- FAIL: ${test_id} (${reason}) ---${C_RESET}" + echo "pre-commit output:" + sed 's/^/ /' "$log" + if [[ -s "$log.diff" ]]; then + echo "diff (expected vs actual):" + sed 's/^/ /' "$log.diff" + fi + rc=1 + fi + + rm -rf "$work" "$config" "$log" "$log.diff" + return "$rc" +} + +function main { + if [[ ! -d $CASES_DIR ]]; then + echo "No cases dir at ${CASES_DIR}" >&2 + exit 1 + fi + + local case_dir + while IFS= read -r -d '' case_dir; do + run_case "$case_dir" || true + done < <(find "$CASES_DIR" -mindepth 2 -maxdepth 2 -type d -print0 | sort -z) + + echo + echo '==== e2e summary ====' + printf ' %s\n' "${summary[@]}" + echo " ${passed} passed, ${failed} failed, ${skipped} skipped" + + [[ $failed -eq 0 ]] +} + +main "$@"