diff --git a/.gitignore b/.gitignore index 2ecd388..d29c8af 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ # Crash log files crash.log + +.vscode/ diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 9f3dbc6..602e59e 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -13,3 +13,11 @@ language: script files: \.tf$ exclude: ^outputs\..*\.tf$ + +- id: provider-pinned-versions + name: "Ensures provider versions are pinned.`" + description: "Ensures Terraform providers are pinned and not in a constrained format." + entry: hooks/provider-pinned-versions/check + language: script + files: \.tf$ + exclude: ^(variables|outputs)\.{0,1}.*\.tf$ diff --git a/hooks/provider-pinned-versions/check b/hooks/provider-pinned-versions/check new file mode 100755 index 0000000..fc7198a --- /dev/null +++ b/hooks/provider-pinned-versions/check @@ -0,0 +1,19 @@ +#!/bin/bash -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AWK_FILE="${SCRIPT_DIR}/required_providers.awk" + +check_files() { + has_error=0 + for file in "$@"; do + awk -f "$AWK_FILE" "$file" || has_error=1 + done + return $has_error +} + +if ! check_files "$@"; then + echo "Providers defined in the 'required_providers' block are not pinned to a specific version." + echo "See: https://developer.hashicorp.com/terraform/language/expressions/version-constraints" +fi + +exit $has_error diff --git a/hooks/provider-pinned-versions/fixtures/fail_when_all_constraints.tf b/hooks/provider-pinned-versions/fixtures/fail_when_all_constraints.tf new file mode 100644 index 0000000..8578865 --- /dev/null +++ b/hooks/provider-pinned-versions/fixtures/fail_when_all_constraints.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~>5.78" + } + newrelic = { + source = "newrelic/newrelic" + version = ">=2, <3" + } + } +} diff --git a/hooks/provider-pinned-versions/fixtures/fail_when_mixed.tf b/hooks/provider-pinned-versions/fixtures/fail_when_mixed.tf new file mode 100644 index 0000000..aad37f5 --- /dev/null +++ b/hooks/provider-pinned-versions/fixtures/fail_when_mixed.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.78.0" + } + newrelic = { + source = "newrelic/newrelic" + version = ">=2, <3" + } + } +} diff --git a/hooks/provider-pinned-versions/fixtures/fail_when_not_full_semver.tf b/hooks/provider-pinned-versions/fixtures/fail_when_not_full_semver.tf new file mode 100644 index 0000000..fa5250a --- /dev/null +++ b/hooks/provider-pinned-versions/fixtures/fail_when_not_full_semver.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.78" + } + newrelic = { + source = "newrelic/newrelic" + version = "3.52.1" + } + } +} diff --git a/hooks/provider-pinned-versions/fixtures/pass_when_all_pinned.tf b/hooks/provider-pinned-versions/fixtures/pass_when_all_pinned.tf new file mode 100644 index 0000000..56fd829 --- /dev/null +++ b/hooks/provider-pinned-versions/fixtures/pass_when_all_pinned.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.78.0" + } + cyral = { + source = "cyralinc/cyral" + version = "4.14.1" + } + mysql = { + source = "petoju/mysql" + version = "3.0.67" + } + newrelic = { + source = "newrelic/newrelic" + version = "3.52.1" + } + turo = { + source = "app.terraform.io/turo/turo" + version = "8.76.0" + } + } +} diff --git a/hooks/provider-pinned-versions/required_providers.awk b/hooks/provider-pinned-versions/required_providers.awk new file mode 100644 index 0000000..ebbb4f8 --- /dev/null +++ b/hooks/provider-pinned-versions/required_providers.awk @@ -0,0 +1,55 @@ +#! /bin/awk + +BEGIN { + # Initialize variables + in_required_providers = 0; + brace_count = 0; + version_prefix_regex = "[[:space:]]*version[[:space:]]+=[[:space:]]+"; +} + +{ + # Check if the line contains "required_providers" + if ($0 ~ /required_providers/) { + in_required_providers = 1; + } + + # If inside "required_providers" block, count braces + if (in_required_providers) { + brace_count += gsub(/{/, "{"); + brace_count -= gsub(/}/, "}"); + + # If brace count returns to 0, exit the block + if (brace_count == 0) { + in_required_providers = 0; + next; + } + + # If the line contains a provider, parse the version + if ($0 ~ /[a-z_-]+[[:space:]]+=[[:space:]]+\{/) { + provider = $1; + # TODO: This doesn't support the following cases: + # - provider version is inlined: provider = { source = "blah" version = "1.2.3" } + # - provider version isn't the second attribute + getline; + getline; + if ($0 ~ version_prefix_regex) { + gsub(version_prefix_regex, "", $0); + if (!($0 ~ /[0-9]+\.[0-9]+\.[0-9]+/)) { + error[++i] = "'" provider "' version not in pinned format: " $0 " in file " FILENAME; + } + } + else { + error[++i] = "No version attribute specified for provider: '" provider "' in file " FILENAME; + } + } + } +} + +END { + if (length(error) > 0) { + for (j = 1; j <= i; j++) { + print error[j] + } + exit 1; + } +} diff --git a/hooks/provider-pinned-versions/test b/hooks/provider-pinned-versions/test new file mode 100755 index 0000000..d95ab03 --- /dev/null +++ b/hooks/provider-pinned-versions/test @@ -0,0 +1,35 @@ +#!/bin/bash -e + +# get the directory of the script +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + +echo "testing: $script_directory" + +# create an array with expected passing files +passing_files=( + "$script_directory/fixtures/pass_when_all_pinned.tf" +) + +# loop over files and check them +for file in "${passing_files[@]}"; do + echo "testing: check $file" + "$script_directory/check" "$file" +done + +failing_files=( + "$script_directory/fixtures/fail_when_all_constraints.tf" + "$script_directory/fixtures/fail_when_mixed.tf" + "$script_directory/fixtures/fail_when_not_full_semver.tf" +) + +# loop over files and check them +for file in "${failing_files[@]}"; do + echo "testing: check $file" + echo " expecting error" + if "$script_directory/check" "$file"; then + echo "ERROR: should have failed" + exit 1 + fi +done + +echo "testing: PASS" diff --git a/script/test b/script/test index 01c8e87..5d3d5ac 100755 --- a/script/test +++ b/script/test @@ -4,3 +4,4 @@ REPO_DIR="$(dirname "$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pw "$REPO_DIR/hooks/outputs-in-outputs-files/test" "$REPO_DIR/hooks/vars-in-variables-files/test" +"$REPO_DIR/hooks/vars-in-variables-files/test"