diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index c5c5f66..8af3d6e 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -90,6 +90,14 @@ files: (\.hcl)$ exclude: \.terraform\/.*$ +- id: terragrunt_validate_inputs + name: Terragrunt validate inputs + description: Validates Terragrunt unused and undefined inputs. + entry: hooks/terragrunt_validate_inputs.sh + language: script + files: (\.hcl)$ + exclude: \.terraform\/.*$ + - id: tofu_tfsec name: OpenTofu validate with tfsec (deprecated, use "tofu_trivy") description: diff --git a/README.md b/README.md index 023ad0c..afb9387 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ If you are using `pre-commit-opentofu` already or want to support its developmen

* [`checkov`](https://github.com/bridgecrewio/checkov) required for `tofu_checkov` hook. * [`terraform-docs`](https://github.com/terraform-docs/terraform-docs) required for `tofu_docs` hook. -* [`terragrunt`](https://terragrunt.gruntwork.io/docs/getting-started/install/) required for `terragrunt_validate` hook. +* [`terragrunt`](https://terragrunt.gruntwork.io/docs/getting-started/install/) required for `terragrunt_validate` and `terragrunt_validate_inputs` hooks. * [`terrascan`](https://github.com/tenable/terrascan) required for `terrascan` hook. * [`TFLint`](https://github.com/terraform-linters/tflint) required for `tofu_tflint` hook. * [`TFSec`](https://github.com/liamg/tfsec) required for `tofu_tfsec` hook. @@ -266,6 +266,32 @@ TAG=latest docker run --rm --entrypoint cat tofuutils/pre-commit-opentofu:$TAG /usr/bin/tools_versions_info ``` +### Example: Terragrunt Input Validation + +Use `terragrunt_validate_inputs` to check that Terragrunt inputs line up with the module variables they are passed into: + +```yaml +repos: +- repo: https://github.com/tofuutils/pre-commit-opentofu + rev: # Get the latest from: https://github.com/tofuutils/pre-commit-opentofu/releases + hooks: + - id: terragrunt_fmt + - id: terragrunt_validate_inputs + args: + - --args=--terragrunt-strict-validate +``` + +> **Note**: This hook automatically uses `terragrunt validate-inputs` for older Terragrunt releases and `terragrunt hcl validate --inputs` for newer releases. +> +> If Terragrunt reports intermittent `.terragrunt-cache` download or `file exists` errors in your repository, run this hook serially in your consumer configuration: +> +> ```yaml +> - id: terragrunt_validate_inputs +> require_serial: true +> args: +> - --args=--terragrunt-strict-validate +> ``` + ## Available Hooks There are several [pre-commit](https://pre-commit.com/) hooks to keep OpenTofu configurations (both `*.tf` and `*.tfvars`) and Terragrunt configurations (`*.hcl`) in a good shape: @@ -286,6 +312,7 @@ There are several [pre-commit](https://pre-commit.com/) hooks to keep OpenTofu c | `tofu_validate` | Validates all Terraform configuration files. [Hook notes](#tofu_validate) | `jq`, only for `--retry-once-with-cleanup` flag | | `terragrunt_fmt` | Reformat all [Terragrunt](https://github.com/gruntwork-io/terragrunt) configuration files (`*.hcl`) to a canonical format. | `terragrunt` | | `terragrunt_validate` | Validates all [Terragrunt](https://github.com/gruntwork-io/terragrunt) configuration files (`*.hcl`) | `terragrunt` | +| `terragrunt_validate_inputs` | Validates Terragrunt unused and undefined inputs. | `terragrunt` | | `tofu_wrapper_module_for_each` | Generates OpenTofu wrappers with `for_each` in module. [Hook notes](#terraform_wrapper_module_for_each) | `hcledit` | | `terrascan` | [terrascan](https://github.com/tenable/terrascan) Detect compliance and security violations. [Hook notes](#terrascan) | `terrascan` | | `tfupdate` | [tfupdate](https://github.com/minamijoyo/tfupdate) Update version constraints of OpenTofu core, providers, and modules. [Hook notes](#tfupdate) | `tfupdate` | diff --git a/hooks/_common.sh b/hooks/_common.sh index 133e457..3f4bdc4 100644 --- a/hooks/_common.sh +++ b/hooks/_common.sh @@ -112,7 +112,7 @@ function common::parse_and_export_env_vars { while true; do # Check if at least 1 env var exists in `$arg` # shellcheck disable=SC2016 # '${' should not be expanded - if [[ "$arg" =~ .*'${'[A-Z_][A-Z0-9_]+?'}'.* ]]; then + if [[ "$arg" =~ .*'${'[A-Z_][A-Z0-9_]*'}'.* ]]; then # Get `ENV_VAR` from `.*${ENV_VAR}.*` local env_var_name=${arg#*$\{} env_var_name=${env_var_name%%\}*} diff --git a/hooks/terragrunt_validate_inputs.sh b/hooks/terragrunt_validate_inputs.sh new file mode 100755 index 0000000..a7b0f78 --- /dev/null +++ b/hooks/terragrunt_validate_inputs.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -eo pipefail + +# globals variables +# shellcheck disable=SC2155 # No way to assign to readonly variable in separate lines +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=_common.sh +. "$SCRIPT_DIR/_common.sh" + +function main { + common::initialize "$SCRIPT_DIR" + common::parse_cmdline "$@" + common::export_provided_env_vars "${ENV_VARS[@]}" + common::parse_and_export_env_vars + # JFYI: terragrunt validate color already suppressed via PRE_COMMIT_COLOR=never + + if terragrunt_version_ge_0_78; then + normalize_validate_args_for_modern_terragrunt + readonly SUBCOMMAND=("hcl" "validate" "--inputs") + readonly RUN_ALL_SUBCOMMAND=("run" "--all" "hcl" "validate" "--inputs") + + # shellcheck disable=SC2153 # False positive + common::per_dir_hook "$HOOK_ID" "${#ARGS[@]}" "${ARGS[@]}" "${FILES[@]}" + return + fi + + run_legacy_validate_inputs +} + +function normalize_validate_args_for_modern_terragrunt { + local arg_idx + + for arg_idx in "${!ARGS[@]}"; do + case "${ARGS[$arg_idx]}" in + --terragrunt-strict-validate|--strict-validate) + ARGS[$arg_idx]="--strict" + ;; + esac + done +} + +function terragrunt_version_ge_0_78 { + local version_raw + local version + local major + local minor + + version_raw=$(terragrunt --version 2>/dev/null || true) + version=$(echo "$version_raw" | sed -E 's/.*v?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1.\2.\3/') + + if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + return 1 + fi + + IFS=. read -r major minor _ <<< "$version" + + if ((major > 0)); then + return 0 + fi + + if ((minor >= 78)); then + return 0 + fi + + return 1 +} + +function run_legacy_validate_inputs { + local -a unit_dirs=() + local final_exit_code=0 + local dir_path + + while read -r dir_path; do + if [[ -n $dir_path ]]; then + unit_dirs+=("$dir_path") + fi + done < <(legacy_unit_dirs_from_files) + + if [[ ${#unit_dirs[@]} -eq 0 ]]; then + return 0 + fi + + # preserve errexit status + shopt -qo errexit && ERREXIT_IS_SET=true + set +e + + for dir_path in "${unit_dirs[@]}"; do + pushd "$dir_path" > /dev/null || continue + terragrunt validate-inputs "${ARGS[@]}" + + local exit_code=$? + if [ $exit_code -ne 0 ]; then + final_exit_code=$exit_code + fi + + popd > /dev/null + done + + [[ $ERREXIT_IS_SET ]] && set -e + exit $final_exit_code +} + +function legacy_unit_dirs_from_files { + local -a unit_files=() + local file_with_path + local file_dir + local file_name + + if common::is_hook_run_on_whole_repo "$HOOK_ID" "${FILES[@]}"; then + find . -type f -name terragrunt.hcl \ + -not -path '*/.terragrunt-cache/*' \ + -not -path '*/.terraform/*' \ + | sort -u | while read -r unit_file; do + dirname "$unit_file" + done + return + fi + + for file_with_path in "${FILES[@]}"; do + file_dir=$(dirname "$file_with_path") + file_name=$(basename "$file_with_path") + + if [[ $file_name == terragrunt.hcl ]]; then + unit_files+=("$file_with_path") + continue + fi + + while read -r unit_file; do + if [[ -n $unit_file ]]; then + unit_files+=("$unit_file") + fi + done < <(find "$file_dir" -type f -name terragrunt.hcl \ + -not -path '*/.terragrunt-cache/*' \ + -not -path '*/.terraform/*' | sort -u) + done + + if [[ ${#unit_files[@]} -eq 0 ]]; then + find . -type f -name terragrunt.hcl \ + -not -path '*/.terragrunt-cache/*' \ + -not -path '*/.terraform/*' \ + | sort -u | while read -r unit_file; do + dirname "$unit_file" + done + return + fi + + printf '%s\n' "${unit_files[@]}" | sort -u | while read -r unit_file; do + dirname "$unit_file" + done +} + +####################################################################### +# Unique part of `common::per_dir_hook`. The function is executed in loop +# on each provided dir path. Run wrapped tool with specified arguments +# Arguments: +# dir_path (string) PATH to dir relative to git repo root. +# Can be used in error logging +# change_dir_in_unique_part (string/false) Modifier which creates +# possibilities to use non-common chdir strategies. +# Availability depends on hook. +# args (array) arguments that configure wrapped tool behavior +# Outputs: +# If failed - print out hook checks status +####################################################################### +function per_dir_hook_unique_part { + # shellcheck disable=SC2034 # Unused var. + local -r dir_path="$1" + # shellcheck disable=SC2034 # Unused var. + local -r change_dir_in_unique_part="$2" + shift 2 + local -a -r args=("$@") + + # pass the arguments to hook + terragrunt "${SUBCOMMAND[@]}" "${args[@]}" + + # return exit code to common::per_dir_hook + local exit_code=$? + return $exit_code +} + +####################################################################### +# Unique part of `common::per_dir_hook`. The function is executed one time +# in the root git repo +# Arguments: +# args (array) arguments that configure wrapped tool behavior +####################################################################### +function run_hook_on_whole_repo { + local -a -r args=("$@") + + # pass the arguments to hook + terragrunt "${RUN_ALL_SUBCOMMAND[@]}" "${args[@]}" + + # return exit code to common::per_dir_hook + local exit_code=$? + return $exit_code +} + +[ "${BASH_SOURCE[0]}" != "$0" ] || main "$@"