diff --git a/.github/workflows/test-test.yaml b/.github/workflows/test-test.yaml new file mode 100644 index 00000000..bd13ef55 --- /dev/null +++ b/.github/workflows/test-test.yaml @@ -0,0 +1,155 @@ +name: Test terraform-test + +on: + - push + +jobs: + default: + runs-on: ubuntu-latest + name: Default inputs + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Test + uses: ./terraform-test + id: test + with: + path: tests/workflows/test-test/local + + - name: Check Passed + run: | + if [[ "${{ steps.test.outputs.failure-reason }}" != "" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi + + filter: + runs-on: ubuntu-latest + name: Default path with a filter + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Test + uses: ./terraform-test + id: test + with: + path: tests/workflows/test-test/local + test_filter: tests/main.tftest.hcl + + - name: Check Passed + run: | + if [[ "${{ steps.test.outputs.failure-reason }}" != "" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi + + test_dir: + runs-on: ubuntu-latest + name: Custom test directory + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Test + uses: ./terraform-test + id: test + with: + path: tests/workflows/test-test/local + test_directory: custom-test-dir + test_filter: | + custom-test-dir/another.tftest.hcl + custom-test-dir/a-third.tftest.hcl + + - name: Check Passed + run: | + if [[ "${{ steps.test.outputs.failure-reason }}" != "" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi + + nonexistent_test_dir: + runs-on: ubuntu-latest + name: Missing test directory + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Test + uses: ./terraform-test + id: nonexistent_test_dir + continue-on-error: true + with: + path: tests/workflows/test-test/local + test_directory: i-dont-exist + + - name: Check failure + run: | + if [[ "${{ steps.nonexistent_test_dir.outcome }}" != "failure" ]]; then + echo "Test did not fail correctly" + exit 1 + fi + + if [[ "${{ steps.nonexistent_test_dir.outputs.failure-reason }}" != "no-tests" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi + + faulty_filter: + runs-on: ubuntu-latest + name: Filter matches no tests + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Test + uses: ./terraform-test + id: faulty_filter + continue-on-error: true + with: + path: tests/workflows/test-test/local + test_filter: | + tests/this-test-does-not-exist.tftest.hcl + tests/nor-does-this-one.tftest.hcl + + - name: Check failure + run: | + if [[ "${{ steps.faulty_filter.outcome }}" != "failure" ]]; then + echo "Test did not fail correctly" + exit 1 + fi + + if [[ "${{ steps.faulty_filter.outputs.failure-reason }}" != "no-tests" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi + + failing: + runs-on: ubuntu-latest + name: A failing test using variables + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Test + uses: ./terraform-test + id: failing + continue-on-error: true + with: + path: tests/workflows/test-test/local + test_filter: tests/main.tftest.hcl + variables: | + length = 1 + + - name: Check failure-reason + run: | + if [[ "${{ steps.failing.outcome }}" != "failure" ]]; then + echo "Test did not fail correctly" + exit 1 + fi + + if [[ "${{ steps.failing.outputs.failure-reason }}" != "tests-failed" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index db21b057..c9c44c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -668,7 +668,7 @@ First release of the GitHub Actions: - [dflook/terraform-new-workspace](terraform-new-workspace) - [dflook/terraform-destroy-workspace](terraform-destroy-workspace) -[1.42.0]: https://github.com/dflook/terraform-github-actions/compare/v1.42.0...v1.42.1 +[1.42.1]: https://github.com/dflook/terraform-github-actions/compare/v1.42.0...v1.42.1 [1.42.0]: https://github.com/dflook/terraform-github-actions/compare/v1.41.2...v1.42.0 [1.41.2]: https://github.com/dflook/terraform-github-actions/compare/v1.41.1...v1.41.2 [1.41.1]: https://github.com/dflook/terraform-github-actions/compare/v1.41.0...v1.41.1 diff --git a/image/actions.sh b/image/actions.sh index 64750749..35624948 100644 --- a/image/actions.sh +++ b/image/actions.sh @@ -176,6 +176,26 @@ function init() { end_group } +## +# Initialize terraform for running tests +# +# This installs modules and providers for the module and all tests +function init-test() { + start_group "Initializing $TOOL_PRODUCT_NAME" + + rm -rf "$TF_DATA_DIR" + + if [[ -n "$INPUT_TEST_DIRECTORY" ]]; then + debug_log $TOOL_COMMAND_NAME init -input=false -backend=false -test-directory "$INPUT_TEST_DIRECTORY" + (cd "$INPUT_PATH" && $TOOL_COMMAND_NAME init -input=false -backend=false -test-directory $INPUT_TEST_DIRECTORY) + else + debug_log $TOOL_COMMAND_NAME init -input=false -backend=false + (cd "$INPUT_PATH" && $TOOL_COMMAND_NAME init -input=false -backend=false) + fi + + end_group +} + function set-init-args() { INIT_ARGS="" @@ -339,15 +359,7 @@ function set-common-plan-args() { fi } -function set-plan-args() { - set-common-plan-args - - if [[ -n "$INPUT_VAR" ]]; then - for var in $(echo "$INPUT_VAR" | tr ',' '\n'); do - PLAN_ARGS="$PLAN_ARGS -var $var" - done - fi - +function set-variable-args() { if [[ -n "$INPUT_VAR_FILE" ]]; then for file in $(echo "$INPUT_VAR_FILE" | tr ',' '\n'); do @@ -364,6 +376,18 @@ function set-plan-args() { echo "$INPUT_VARIABLES" >"$STEP_TMP_DIR/variables.tfvars" PLAN_ARGS="$PLAN_ARGS -var-file=$STEP_TMP_DIR/variables.tfvars" fi +} + +function set-plan-args() { + set-common-plan-args + + if [[ -n "$INPUT_VAR" ]]; then + for var in $(echo "$INPUT_VAR" | tr ',' '\n'); do + PLAN_ARGS="$PLAN_ARGS -var $var" + done + fi + + set-variable-args export PLAN_ARGS } diff --git a/image/entrypoints/test.sh b/image/entrypoints/test.sh new file mode 100755 index 00000000..1a58a8e3 --- /dev/null +++ b/image/entrypoints/test.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# shellcheck source=../actions.sh +source /usr/local/actions.sh + +debug +setup +init-test + +exec 3>&1 + +function set-test-args() { + TEST_ARGS="" + + if [[ -v INPUT_CLOUD_RUN && -n "$INPUT_CLOUD_RUN" ]]; then + # I have no idea what this does, it is not well documented. + TEST_ARGS="$TEST_ARGS -cloud-run=$INPUT_CLOUD_RUN" + fi + + if [[ -n "$INPUT_TEST_DIRECTORY" ]]; then + TEST_ARGS="$TEST_ARGS -test-directory=$INPUT_TEST_DIRECTORY" + fi + + if [[ -n "$INPUT_TEST_FILTER" ]]; then + for file in $(echo "$INPUT_TEST_FILTER" | tr ',' '\n'); do + TEST_ARGS="$TEST_ARGS -filter=$file" + done + fi +} + +function test() { + + debug_log $TOOL_COMMAND_NAME test -no-color $TEST_ARGS '$PLAN_ARGS' # don't expand PLAN_ARGS + + set +e + # shellcheck disable=SC2086 + (cd "$INPUT_PATH" && $TOOL_COMMAND_NAME test -no-color $TEST_ARGS $PLAN_ARGS) \ + 2>"$STEP_TMP_DIR/terraform_test.stderr" \ + | tee /dev/fd/3 \ + >"$STEP_TMP_DIR/terraform_test.stdout" + + # shellcheck disable=SC2034 + TEST_EXIT=${PIPESTATUS[0]} + set -e + + cat "$STEP_TMP_DIR/terraform_test.stderr" + + if [[ $TEST_EXIT -eq 0 ]]; then + # Workaround a bit of stupidity in the terraform test command + if grep -q "Success! 0 passed, 0 failed." "$STEP_TMP_DIR/terraform_test.stdout"; then + error_log "No tests found" + set_output failure-reason no-tests + exit 1 + fi + else + set_output failure-reason tests-failed + exit 1 + fi +} + +set-test-args +PLAN_ARGS="" +set-variable-args + +test diff --git a/terraform-test/README.md b/terraform-test/README.md new file mode 100644 index 00000000..0ff3ddfe --- /dev/null +++ b/terraform-test/README.md @@ -0,0 +1,208 @@ +# terraform-test action + +This is one of a suite of Terraform related actions - find them at [dflook/terraform-github-actions](https://github.com/dflook/terraform-github-actions). + +Execute automated tests on a Terraform module using the built-in `terraform test` command. + +## Inputs + +* `path` + + Path to the Terraform module under test + + - Type: string + - Optional + - Default: The action workspace + +* `test_directory` + + The directory within the module path that contains the test files. + + ```yaml + with: + test_directory: tf_tests + ``` + + - Type: string + - Optional + - Default: `tests` + +* `test_filter` + + The test files to run, one per line. + + If not specified, all test files in the `test_directory` will be run. + The are paths relative to the module path. + + ```yaml + with: + test_filter: | + tests/main.tftest.hcl + tests/other.tftest.hcl + ``` + + - Type: string + - Optional + - Default: All test files in the `test_directory` + +* `variables` + + Variables to set for the tests. This should be valid Terraform syntax - like a [variable definition file](https://www.terraform.io/docs/language/values/variables.html#variable-definitions-tfvars-files). + + ```yaml + with: + variables: | + image_id = "${{ secrets.AMI_ID }}" + availability_zone_names = [ + "us-east-1a", + "us-west-1c", + ] + ``` + + Variables set here override any given in `var_file`s. + + - Type: string + - Optional + +* `var_file` + + List of tfvars files to use, one per line. + Paths should be relative to the GitHub Actions workspace + + ```yaml + with: + var_file: | + common.tfvars + prod.tfvars + ``` + + - Type: string + - Optional + +## Outputs + +* `failure-reason` + + When the job outcome is `failure`, this output may be set. The value may be one of: + + - `no-tests` - No tests were found to run. + - `tests-failed` - One or more tests failed. + + If the job fails for any other reason this will not be set. + This can be used with the Actions expression syntax to conditionally run steps. + +## Environment Variables + +* `GITHUB_DOT_COM_TOKEN` + + This is used to specify a token for GitHub.com when the action is running on a GitHub Enterprise instance. + This is only used for downloading OpenTofu binaries from GitHub.com. + If this is not set, an unauthenticated request will be made to GitHub.com to download the binary, which may be rate limited. + + - Type: string + - Optional + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for cloud hosts, of the form `=`. Multiple tokens may be specified, one per line. + These tokens may be used with the `remote` backend and for fetching required modules from the registry. + + e.g: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With other registries: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: | + app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + terraform.example.com=${{ secrets.TF_REGISTRY_TOKEN }} + ``` + + - Type: string + - Optional + +* `TERRAFORM_SSH_KEY` + + A SSH private key that Terraform will use to fetch git/mercurial module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - Type: string + - Optional + +* `TERRAFORM_HTTP_CREDENTIALS` + + Credentials that will be used for fetching modules sources with `git::http://`, `git::https://`, `http://` & `https://` schemes. + + Credentials have the format `=:`. Multiple credentials may be specified, one per line. + + Each credential is evaluated in order, and the first matching credentials are used. + + Credentials that are used by git (`git::http://`, `git::https://`) allow a path after the hostname. + Paths are ignored by `http://` & `https://` schemes. + For git module sources, a credential matches if each mentioned path segment is an exact match. + + For example: + ```yaml + env: + TERRAFORM_HTTP_CREDENTIALS: | + example.com=dflook:${{ secrets.HTTPS_PASSWORD }} + github.com/dflook/terraform-github-actions.git=dflook-actions:${{ secrets.ACTIONS_PAT }} + github.com/dflook=dflook:${{ secrets.DFLOOK_PAT }} + github.com=graham:${{ secrets.GITHUB_PAT }} + ``` + + - Type: string + - Optional + +* `TERRAFORM_PRE_RUN` + + A set of commands that will be ran prior to `terraform init`. This can be used to customise the environment before running Terraform. + + The runtime environment for these actions is subject to change in minor version releases. If using this environment variable, specify the minor version of the action to use. + + The runtime image is currently based on `debian:bullseye`, with the command run using `bash -xeo pipefail`. + + For example: + ```yaml + env: + TERRAFORM_PRE_RUN: | + # Install latest Azure CLI + curl -skL https://aka.ms/InstallAzureCLIDeb | bash + + # Install postgres client + apt-get install -y --no-install-recommends postgresql-client + ``` + + - Type: string + - Optional + +## Example usage + +```yaml +name: "Run Tests" + +on: [push] + +jobs: + test: + name: Unlock + runs-on: ubuntu-latest + steps: + - name: Checkout current branch + uses: actions/checkout@v3 + + - name: Terraform Tests + uses: dflook/terraform-test@v1 + with: + path: modules/vpc +``` diff --git a/terraform-test/action.yaml b/terraform-test/action.yaml new file mode 100644 index 00000000..06f69c86 --- /dev/null +++ b/terraform-test/action.yaml @@ -0,0 +1,36 @@ +name: terraform-test +description: Execute automated tests for a Terraform module +author: Daniel Flook + +inputs: + path: + description: Path to the Terraform module under test + required: false + default: . + test_directory: + description: Path to the Terraform tests + required: false + default: "" + test_filter: + description: Test files to run within the test_directory + required: false + default: "" + variables: + description: Variable definitions + required: false + var_file: + description: List of var file paths, one per line + required: false + +outputs: + failure-reason: + description: The reason for the build failure. May be `no-tests` or `tests-failed`. + +runs: + using: docker + image: ../image/Dockerfile + entrypoint: /entrypoints/test.sh + +branding: + icon: globe + color: purple diff --git a/tests/workflows/test-test/local/custom-test-dir/a-third.tftest.hcl b/tests/workflows/test-test/local/custom-test-dir/a-third.tftest.hcl new file mode 100644 index 00000000..988d59a9 --- /dev/null +++ b/tests/workflows/test-test/local/custom-test-dir/a-third.tftest.hcl @@ -0,0 +1,21 @@ +run "require_provider" { + # This will require terraform to be initialised correctly to bring in the time provider + command = apply + + module { + source = "./sleep" + } +} + +run "test_third" { + command = apply + + variables { + length = 15 + } + + assert { + condition = length(output.random_string) == 15 + error_message = "Length is not correct" + } +} diff --git a/tests/workflows/test-test/local/custom-test-dir/another.tftest.hcl b/tests/workflows/test-test/local/custom-test-dir/another.tftest.hcl new file mode 100644 index 00000000..a8d5195d --- /dev/null +++ b/tests/workflows/test-test/local/custom-test-dir/another.tftest.hcl @@ -0,0 +1,12 @@ +run "test_length" { + command = apply + + variables { + length = 12 + } + + assert { + condition = length(output.random_string) == 12 + error_message = "Length is not correct" + } +} diff --git a/tests/workflows/test-test/local/custom-test-dir/main.tftest.hcl b/tests/workflows/test-test/local/custom-test-dir/main.tftest.hcl new file mode 100644 index 00000000..dc8808d2 --- /dev/null +++ b/tests/workflows/test-test/local/custom-test-dir/main.tftest.hcl @@ -0,0 +1,12 @@ +run "test_length" { + command = apply + + variables { + length = 11 + } + + assert { + condition = length(output.random_string) == 11 + error_message = "Length is not correct" + } +} diff --git a/tests/workflows/test-test/local/main.tf b/tests/workflows/test-test/local/main.tf new file mode 100644 index 00000000..afd915cc --- /dev/null +++ b/tests/workflows/test-test/local/main.tf @@ -0,0 +1,14 @@ +variable "length" { + description = "The length of the string" + type = number + default = 10 +} + +resource "random_string" "example" { + length = var.length + special = false +} + +output "random_string" { + value = random_string.example.result +} diff --git a/tests/workflows/test-test/local/sleep/main.tf b/tests/workflows/test-test/local/sleep/main.tf new file mode 100644 index 00000000..e5913152 --- /dev/null +++ b/tests/workflows/test-test/local/sleep/main.tf @@ -0,0 +1,3 @@ +resource "time_sleep" "this" { + create_duration = "1s" +} \ No newline at end of file diff --git a/tests/workflows/test-test/local/tests/another.tftest.hcl b/tests/workflows/test-test/local/tests/another.tftest.hcl new file mode 100644 index 00000000..e2f60480 --- /dev/null +++ b/tests/workflows/test-test/local/tests/another.tftest.hcl @@ -0,0 +1,12 @@ +run "test_length" { + command = apply + + variables { + length = 13 + } + + assert { + condition = length(output.random_string) == 13 + error_message = "Length is not correct" + } +} diff --git a/tests/workflows/test-test/local/tests/main.tftest.hcl b/tests/workflows/test-test/local/tests/main.tftest.hcl new file mode 100644 index 00000000..0aec8843 --- /dev/null +++ b/tests/workflows/test-test/local/tests/main.tftest.hcl @@ -0,0 +1,8 @@ +run "test" { + command = apply + + assert { + condition = length(output.random_string) == 10 + error_message = "Length is not correct" + } +} diff --git a/tofu-test/README.md b/tofu-test/README.md new file mode 100644 index 00000000..9569260c --- /dev/null +++ b/tofu-test/README.md @@ -0,0 +1,208 @@ +# tofu-test action + +This is one of a suite of OpenTofu related actions - find them at [dflook/terraform-github-actions](https://github.com/dflook/terraform-github-actions). + +Execute automated tests on an OpenTofu module using the built-in `tofu test` command. + +## Inputs + +* `path` + + Path to the OpenTofu module under test + + - Type: string + - Optional + - Default: The action workspace + +* `test_directory` + + The directory within the module path that contains the test files. + + ```yaml + with: + test_directory: tf_tests + ``` + + - Type: string + - Optional + - Default: `tests` + +* `test_filter` + + The test files to run, one per line. + + If not specified, all test files in the `test_directory` will be run. + The are paths relative to the module path. + + ```yaml + with: + test_filter: | + tests/main.tftest.hcl + tests/other.tftest.hcl + ``` + + - Type: string + - Optional + - Default: All test files in the `test_directory` + +* `variables` + + Variables to set for the tests. This should be valid OpenTofu syntax - like a [variable definition file](https://www.terraform.io/docs/language/values/variables.html#variable-definitions-tfvars-files). + + ```yaml + with: + variables: | + image_id = "${{ secrets.AMI_ID }}" + availability_zone_names = [ + "us-east-1a", + "us-west-1c", + ] + ``` + + Variables set here override any given in `var_file`s. + + - Type: string + - Optional + +* `var_file` + + List of tfvars files to use, one per line. + Paths should be relative to the GitHub Actions workspace + + ```yaml + with: + var_file: | + common.tfvars + prod.tfvars + ``` + + - Type: string + - Optional + +## Outputs + +* `failure-reason` + + When the job outcome is `failure`, this output may be set. The value may be one of: + + - `no-tests` - No tests were found to run. + - `tests-failed` - One or more tests failed. + + If the job fails for any other reason this will not be set. + This can be used with the Actions expression syntax to conditionally run steps. + +## Environment Variables + +* `GITHUB_DOT_COM_TOKEN` + + This is used to specify a token for GitHub.com when the action is running on a GitHub Enterprise instance. + This is only used for downloading OpenTofu binaries from GitHub.com. + If this is not set, an unauthenticated request will be made to GitHub.com to download the binary, which may be rate limited. + + - Type: string + - Optional + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for cloud hosts, of the form `=`. Multiple tokens may be specified, one per line. + These tokens may be used with the `remote` backend and for fetching required modules from the registry. + + e.g: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With other registries: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: | + app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + tofu.example.com=${{ secrets.TF_REGISTRY_TOKEN }} + ``` + + - Type: string + - Optional + +* `TERRAFORM_SSH_KEY` + + A SSH private key that OpenTofu will use to fetch git/mercurial module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - Type: string + - Optional + +* `TERRAFORM_HTTP_CREDENTIALS` + + Credentials that will be used for fetching modules sources with `git::http://`, `git::https://`, `http://` & `https://` schemes. + + Credentials have the format `=:`. Multiple credentials may be specified, one per line. + + Each credential is evaluated in order, and the first matching credentials are used. + + Credentials that are used by git (`git::http://`, `git::https://`) allow a path after the hostname. + Paths are ignored by `http://` & `https://` schemes. + For git module sources, a credential matches if each mentioned path segment is an exact match. + + For example: + ```yaml + env: + TERRAFORM_HTTP_CREDENTIALS: | + example.com=dflook:${{ secrets.HTTPS_PASSWORD }} + github.com/dflook/terraform-github-actions.git=dflook-actions:${{ secrets.ACTIONS_PAT }} + github.com/dflook=dflook:${{ secrets.DFLOOK_PAT }} + github.com=graham:${{ secrets.GITHUB_PAT }} + ``` + + - Type: string + - Optional + +* `TERRAFORM_PRE_RUN` + + A set of commands that will be ran prior to `tofu init`. This can be used to customise the environment before running OpenTofu. + + The runtime environment for these actions is subject to change in minor version releases. If using this environment variable, specify the minor version of the action to use. + + The runtime image is currently based on `debian:bullseye`, with the command run using `bash -xeo pipefail`. + + For example: + ```yaml + env: + TERRAFORM_PRE_RUN: | + # Install latest Azure CLI + curl -skL https://aka.ms/InstallAzureCLIDeb | bash + + # Install postgres client + apt-get install -y --no-install-recommends postgresql-client + ``` + + - Type: string + - Optional + +## Example usage + +```yaml +name: "Run Tests" + +on: [push] + +jobs: + test: + name: Unlock + runs-on: ubuntu-latest + steps: + - name: Checkout current branch + uses: actions/checkout@v3 + + - name: OpenTofu Tests + uses: dflook/tofu-test@v1 + with: + path: modules/vpc +``` diff --git a/tofu-test/action.yaml b/tofu-test/action.yaml new file mode 100644 index 00000000..0b8dafe4 --- /dev/null +++ b/tofu-test/action.yaml @@ -0,0 +1,38 @@ +name: tofu-test +description: Execute automated tests for an OpenTofu module +author: Daniel Flook + +inputs: + path: + description: Path to the OpenTofu module under test + required: false + default: . + test_directory: + description: Path to the OpenTofu tests + required: false + default: "" + test_filter: + description: Test files to run within the test_directory + required: false + default: "" + variables: + description: Variable definitions + required: false + var_file: + description: List of var file paths, one per line + required: false + +outputs: + failure-reason: + description: The reason for the build failure. May be `no-tests` or `tests-failed`. + +runs: + env: + OPENTOFU: true + using: docker + image: ../image/Dockerfile + entrypoint: /entrypoints/test.sh + +branding: + icon: globe + color: purple