diff --git a/.github/actions/action_tests/action.yaml b/.github/actions/action_tests/action.yaml index 4e96f3595..b0ce2cd05 100644 --- a/.github/actions/action_tests/action.yaml +++ b/.github/actions/action_tests/action.yaml @@ -36,6 +36,21 @@ runs: with: version: 0.7.8 + # The terraform-docs action's pre-commit hook shells out to + # `terraform-docs`, so the binary needs to be on PATH inside the action + # sandbox. There is no maintained setup-terraform-docs action, so install + # the release tarball directly. Action Tests only run on ubuntu-latest. + - name: Setup terraform-docs + run: | + version=0.16.0 + tmp=$(mktemp -d) + curl -sSLo "$tmp/terraform-docs.tgz" \ + "https://github.com/terraform-docs/terraform-docs/releases/download/v${version}/terraform-docs-v${version}-linux-amd64.tar.gz" + tar -xzf "$tmp/terraform-docs.tgz" -C "$tmp" + sudo install -m 0755 "$tmp/terraform-docs" /usr/local/bin/terraform-docs + terraform-docs --version + shell: bash + - name: Specify defaults run: | echo "CLI_PATH=${{ inputs.cli-path }}" >> "$GITHUB_ENV" diff --git a/.gitignore b/.gitignore index 75a65a35c..2b615edcd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ junit.xml # Snyk .dccache + +# Python +__pycache__/ +*.pyc diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 4139fa85a..a2bab2477 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -104,6 +104,7 @@ actions: enabled: # enabled actions inherited from github.com/trunk-io/configs plugin + - terraform-docs - linter-test-helper - npm-check-pre-push - remove-release-snapshots diff --git a/actions/terraform-docs/terraform-docs.py b/actions/terraform-docs/terraform-docs.py index 26f56ddb1..738cc16f2 100755 --- a/actions/terraform-docs/terraform-docs.py +++ b/actions/terraform-docs/terraform-docs.py @@ -4,33 +4,38 @@ This script acts as a pre-commit hook to ensure terraform documentation is up to date. It performs the following: -1. Runs terraform-docs to update documentation -2. Checks if any README.md files show up in the unstaged changes -3. Exits with failure if there are unstaged README changes, success otherwise +1. Finds directories where Terraform files have changed +2. Runs terraform-docs in each directory containing changed Terraform files +3. Checks if any README.md files show up in the unstaged changes +4. Exits with failure if there are unstaged README changes, success otherwise """ -# trunk-ignore(bandit/B404) -import subprocess +import os +import subprocess # trunk-ignore(bandit/B404) import sys +TERRAFORM_EXTENSIONS = (".tf", ".tofu", ".tfvars") +CONFIG_FILENAME = ".terraform-docs.yaml" -def run_command(cmd): + +def run_command(cmd, cwd=None): """ Execute a shell command and return its exit code, stdout, and stderr. Args: cmd: List of command arguments to execute + cwd: Optional working directory in which to run the command Returns: Tuple containing (return_code, stdout, stderr) """ try: - process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, + cwd=cwd, # trunk-ignore(bandit/B603) shell=False, # Explicitly disable shell to prevent command injection ) @@ -46,28 +51,104 @@ def run_command(cmd): sys.exit(1) -# First, run terraform-docs to update documentation -update_cmd = ["terraform-docs", "."] -return_code, stdout, stderr = run_command(update_cmd) +def terraform_dirs_from_paths(paths, repo_root="."): + """ + Given an iterable of repo-relative file paths, return the set of directories + that contain Terraform files and still exist on disk. + + Deleted files are skipped because their parent directory may no longer exist + (or the module may have been removed entirely), and there's nothing for + terraform-docs to document there. + """ + dirs = set() + for file_path in paths: + file_path = file_path.strip() + if not file_path or not file_path.endswith(TERRAFORM_EXTENSIONS): + continue + dir_path = os.path.dirname(file_path) or "." + abs_dir = ( + dir_path if os.path.isabs(dir_path) else os.path.join(repo_root, dir_path) + ) + if os.path.isdir(abs_dir): + dirs.add(dir_path) + return dirs + + +def get_changed_terraform_directories(): + """ + Return the set of directories containing Terraform files that are part of + this commit (staged) or have been modified in the working tree. + + The hook runs pre-commit, so staged changes are the primary source of truth; + we also include unstaged edits so that a developer iterating in the working + tree sees their docs regenerated. + """ + paths = set() + for diff_args in (["--cached", "--name-only"], ["--name-only"]): + cmd = ["git", "diff", *diff_args] + return_code, stdout, _stderr = run_command(cmd) + if return_code != 0: + continue + paths.update(stdout.splitlines()) + return terraform_dirs_from_paths(paths) + + +def build_terraform_docs_cmd(repo_root): + """Pick the terraform-docs invocation based on whether a config file exists.""" + config_file_path = os.path.join(repo_root, CONFIG_FILENAME) + if os.path.exists(config_file_path): + return ["terraform-docs", "--config", config_file_path, "."] + return ["terraform-docs", "markdown-table", "."] -if stderr: - print(f"terraform-docs error: Warning during execution:\n{stderr}", file=sys.stderr) -# Check git status for unstaged README changes -status_cmd = ["git", "status", "--porcelain"] -return_code, stdout, stderr = run_command(status_cmd) +def find_unstaged_readmes(porcelain_output): + """ + Parse `git status --porcelain` output and return README.md paths that are + either modified-but-unstaged or untracked. Both states block the commit + because the developer needs to `git add` the regenerated docs. + """ + unstaged = [] + for line in porcelain_output.splitlines(): + if len(line) < 3: + continue + status = line[:2] + path = line[3:].strip() + # `_M` = unstaged modification (any X), `??` = untracked. + if (status[1] == "M" or status == "??") and path.endswith("README.md"): + unstaged.append(path) + return unstaged + + +def main(): + repo_root = os.getcwd() + terraform_dirs = get_changed_terraform_directories() + + if not terraform_dirs: + print( + "terraform-docs: No Terraform files changed, skipping documentation update" + ) + return 0 + + update_cmd = build_terraform_docs_cmd(repo_root) + + for directory in sorted(terraform_dirs): + print(f"terraform-docs: Updating documentation in {directory}") + target = directory if directory != "." else repo_root + _return_code, _stdout, stderr = run_command(update_cmd, cwd=target) + if stderr: + print(f"terraform-docs warning in {directory}: {stderr}", file=sys.stderr) + + _return_code, stdout, _stderr = run_command(["git", "status", "--porcelain"]) + unstaged_readmes = find_unstaged_readmes(stdout) + if unstaged_readmes: + print( + "terraform-docs error: Please stage any README changes before committing." + ) + return 1 -# Look for any README.md files in the unstaged changes -unstaged_readmes = [ - line.split()[-1] - for line in stdout.splitlines() - if line.startswith(" M") and line.endswith("README.md") -] + print("terraform-docs: Documentation is up to date") + return 0 -# Check if we found any unstaged README files -if len(unstaged_readmes) > 0: - print("terraform-docs error: Please stage any README changes before committing.") - sys.exit(1) -print("terraform-docs: Documentation is up to date") -sys.exit(0) +if __name__ == "__main__": + sys.exit(main()) diff --git a/actions/terraform-docs/terraform_docs.test.ts b/actions/terraform-docs/terraform_docs.test.ts new file mode 100644 index 000000000..7f2572f36 --- /dev/null +++ b/actions/terraform-docs/terraform_docs.test.ts @@ -0,0 +1,52 @@ +import { actionRunTest } from "tests"; +import { TrunkActionDriver } from "tests/driver"; + +const preCheck = (driver: TrunkActionDriver) => { + // The action shells out to `terraform-docs`, so the tool's shim has to be on + // PATH inside the sandbox. The base trunk.yaml only enables the action; we + // append a `tools` block here. + const trunkYamlPath = ".trunk/trunk.yaml"; + driver.writeFile( + trunkYamlPath, + driver.readFile(trunkYamlPath).concat(` +tools: + enabled: + - terraform-docs@0.16.0 +`), + ); + + driver.writeFile( + "modules/a/main.tf", + `variable "name" { + description = "Example input." + type = string +} +`, + ); +}; + +const testCallback = async (driver: TrunkActionDriver) => { + // Stage the new .tf file so the pre-commit hook's `git diff --cached` + // picks it up and runs terraform-docs against modules/a. + await driver.gitDriver?.add("modules/a/main.tf"); + + let commitError: Error | undefined; + try { + await driver.gitDriver?.commit("Add module a", [], { "--allow-empty": null }); + } catch (err) { + commitError = err as Error; + } + + // terraform-docs regenerates modules/a/README.md, which is untracked at + // commit time. The hook should reject the commit until the developer + // stages the new doc. + expect(commitError).toBeDefined(); + expect(commitError?.message).toContain("Please stage any README changes before committing."); +}; + +actionRunTest({ + actionName: "terraform-docs", + syncGitHooks: true, + preCheck, + testCallback, +});