diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..24411b1b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: + - dflook diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c8695979 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +contact_links: + - name: Question + url: https://github.com/dflook/terraform-github-actions/discussions + about: Please ask questions as a new discussion if others would benefit from seeing the answer diff --git a/.github/ISSUE_TEMPLATE/problem.yml b/.github/ISSUE_TEMPLATE/problem.yml new file mode 100644 index 00000000..77d4108a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/problem.yml @@ -0,0 +1,44 @@ +name: Problem +description: I'm having a problem using these actions +labels: + - problem +body: + - type: markdown + attributes: + value: | + Before creating an issue, enable debug logging by setting the `ACTIONS_STEP_DEBUG` secret to `true` and run the job again. + - type: textarea + id: description + attributes: + description: What is not working? What do you think should be happening instead? + label: Problem description + validations: + required: true + + - type: input + id: terraform-version + attributes: + label: Terraform version + description: What terraform version are you using? + placeholder: 1.0.5 + + - type: input + id: backend + attributes: + label: Backend + description: What terraform backend are you using? + placeholder: s3 + + - type: textarea + id: workflow + attributes: + label: Workflow YAML + description: Please copy and paste the relevant workflow yaml. This will be automatically formatted, so no need for backticks. + render: yaml + + - type: textarea + id: workflow-logs + attributes: + label: Workflow log + description: Please copy and paste the relevant workflow log output. If this is long consider putting in a [gist](https://gist.github.com/). This will be automatically formatted, so no need for backticks. + render: shell diff --git a/.github/ISSUE_TEMPLATE/suggestion.yml b/.github/ISSUE_TEMPLATE/suggestion.yml new file mode 100644 index 00000000..9a846c1f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/suggestion.yml @@ -0,0 +1,11 @@ +name: Suggestion +description: I have a suggestion for an enhancement +labels: + - enhancement +body: + - type: textarea + id: suggestion + attributes: + label: Suggestion + validations: + required: true diff --git a/.github/github_sucks.md b/.github/github_sucks.md index cee65b7f..9f9f9874 100644 --- a/.github/github_sucks.md +++ b/.github/github_sucks.md @@ -1,3 +1,4 @@ Everytime I need to generate a push or synchronise event I will touch this file. This is usually because GitHub Actions has broken in some way. + diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 00000000..7c07da04 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,30 @@ +- name: defect + color: d73a4a + description: Something isn't working + +- name: documentation + color: 0075ca + description: Improvements or additions to documentation + +- name: duplicate + color: cfd3d7 + description: This issue or pull request already exists + +- name: enhancement + color: a2eeef + description: New feature or request + +- name: invalid + color: e4e669 + description: This doesn't seem right + +- name: problem + color: BFD4F2 + +- name: question + color: d876e3 + description: Further information is requested + +- name: wontfix + color: ffffff + description: This will not be worked on diff --git a/.github/release_template.md b/.github/release_template.md index b8255446..71bdbfe7 100644 --- a/.github/release_template.md +++ b/.github/release_template.md @@ -1,6 +1,6 @@ This is one of a suite of terraform related actions - find them at [dflook/terraform-github-actions](https://github.com/dflook/terraform-github-actions). -You can see the changes for this release in the [CHANGELOG](https://github.com/dflook/terraform-github-actions/blob/master/CHANGELOG.md) +You can see the changes for this release in the [CHANGELOG](https://github.com/dflook/terraform-github-actions/blob/main/CHANGELOG.md) You can specify the action version as: diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index ee4c423f..7349ff1c 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -3,9 +3,12 @@ name: Update base image on: push: branches: - - master + - main paths: - image/Dockerfile-base + - .github/workflows/base-image.yaml + schedule: + - cron: 0 1 * * 1 jobs: push_image: @@ -24,9 +27,23 @@ jobs: - name: Base image run: | - docker build --tag dflook/terraform-github-actions-base -f image/Dockerfile-base image + docker pull --quiet debian:bullseye-slim + BASE_DIGEST="$(docker image inspect --format="{{index .RepoDigests 0}}" "debian:bullseye-slim" | sed 's/.*@//')" + + docker build --tag dflook/terraform-github-actions-base -f image/Dockerfile-base \ + --label org.opencontainers.image.created="$(date '+%Y-%m-%dT%H:%M:%S%z')" \ + --label org.opencontainers.image.source="https://github.com/${{ github.repository }}" \ + --label org.opencontainers.image.revision="${{ github.sha }}" \ + --label org.opencontainers.image.base.name="docker.io/library/debian:bullseye-slim" \ + --label org.opencontainers.image.base.digest="$BASE_DIGEST" \ + --label build="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + image docker tag dflook/terraform-github-actions-base danielflook/terraform-github-actions-base:latest - docker push danielflook/terraform-github-actions-base:latest + docker push --quiet danielflook/terraform-github-actions-base:latest + + IMAGE_DIGEST="$(docker image inspect --format="{{index .RepoDigests 0}}" "danielflook/terraform-github-actions-base:latest" | sed 's/.*@//')" + echo "::set-output name=digest::$IMAGE_DIGEST" + docker tag dflook/terraform-github-actions-base danielflook/terraform-github-actions-base:$GITHUB_RUN_ID - docker push danielflook/terraform-github-actions-base:$GITHUB_RUN_ID + docker push --quiet danielflook/terraform-github-actions-base:$GITHUB_RUN_ID diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml new file mode 100644 index 00000000..490573e1 --- /dev/null +++ b/.github/workflows/labels.yaml @@ -0,0 +1,20 @@ +name: Update labels + +on: + push: + branches: + - master + paths: + - .github/labels.yml + +jobs: + labeler: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Run Labeler + uses: crazy-max/ghaction-github-labeler@52525cb66833763f651fc34e244e4f73b6e07ff5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request_review.yaml b/.github/workflows/pull_request_review.yaml new file mode 100644 index 00000000..2fa0f4f9 --- /dev/null +++ b/.github/workflows/pull_request_review.yaml @@ -0,0 +1,34 @@ +name: pull_request_review test + +on: + - pull_request_review + +jobs: + apply: + runs-on: ubuntu-latest + name: Apply approved changes on pull_request_review + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Plan + uses: ./terraform-plan + with: + label: pull_request_review + path: tests/workflows/pull_request_review + + - name: Apply + uses: ./terraform-apply + id: output + with: + label: pull_request_review + path: tests/workflows/pull_request_review + + - name: Verify outputs + run: | + if [[ "${{ steps.output.outputs.output_string }}" != "the_string" ]]; then + echo "::error:: output s not set correctly" + exit 1 + fi diff --git a/.github/workflows/pull_request_review_trigger.yaml b/.github/workflows/pull_request_review_trigger.yaml new file mode 100644 index 00000000..067121a3 --- /dev/null +++ b/.github/workflows/pull_request_review_trigger.yaml @@ -0,0 +1,27 @@ +name: Trigger pull_request_review + +on: + - pull_request + +jobs: + required_version: + runs-on: ubuntu-latest + name: pull_request_review + steps: + - name: Trigger pull_request_review event + run: | + cat >review.json < fixed-workspace-name/main.tf + else + sed -e 's/prefix.*/name = "github-actions-1-1-${{ github.head_ref }}-1"/' tests/workflows/test-cloud/${{ matrix.tf_version }}/main.tf > fixed-workspace-name/main.tf + fi + + - name: Get outputs + uses: ./terraform-output + id: name-output + with: + path: fixed-workspace-name + backend_config: token=${{ secrets.TF_API_TOKEN }} + + - name: Verify auto_apply terraform outputs with workspace name + run: | + if [[ "${{ steps.name-output.outputs.default }}" != "default" ]]; then + echo "::error:: Variables not set correctly" + exit 1 + fi + + - name: Check no changes + uses: ./terraform-check + with: + path: tests/workflows/test-cloud/${{ matrix.tf_version }} + workspace: ${{ github.head_ref }}-1 + backend_config: token=${{ secrets.TF_API_TOKEN }} + var_file: | + tests/workflows/test-cloud/${{ matrix.tf_version }}/my_variable.tfvars + variables: | + from_variables="from_variables" + + - name: Check changes + uses: ./terraform-check + id: check + continue-on-error: true + with: + path: tests/workflows/test-cloud/${{ matrix.tf_version }} + workspace: ${{ github.head_ref }}-1 + backend_config: token=${{ secrets.TF_API_TOKEN }} + var_file: | + tests/workflows/test-cloud/${{ matrix.tf_version }}/my_variable.tfvars + variables: | + from_variables="Changed!" + + - name: Verify changes detected + run: | + if [[ "${{ steps.check.outcome }}" != "failure" ]]; then + echo "Check didn't fail correctly" + exit 1 + fi + + if [[ "${{ steps.check.outputs.failure-reason }}" != "changes-to-apply" ]]; then + echo "failure-reason not set correctly" + exit 1 + fi + + - name: Destroy workspace + uses: ./terraform-destroy-workspace + with: + path: tests/workflows/test-cloud/${{ matrix.tf_version }} + workspace: ${{ github.head_ref }}-1 + backend_config: token=${{ secrets.TF_API_TOKEN }} + + - name: Plan workspace + uses: ./terraform-plan + id: plan + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path: tests/workflows/test-cloud/${{ matrix.tf_version }} + workspace: ${{ github.head_ref }}-2 + backend_config: token=${{ secrets.TF_API_TOKEN }} + var_file: | + tests/workflows/test-cloud/${{ matrix.tf_version }}/my_variable.tfvars + variables: | + from_variables="from_variables" + + - name: Verify plan outputs + run: | + if [[ "${{ steps.plan.outputs.changes }}" != "true" ]]; then + echo "::error:: output changes not set correctly" + exit 1 + fi + + if ! grep -q "Terraform will perform the following actions" '${{ steps.plan.outputs.text_plan_path }}'; then + echo "::error:: text_plan_path not set correctly" + exit 1 + fi + + echo '${{ steps.plan.outputs.run_id }}' + if [[ "${{ steps.plan.outputs.run_id }}" != "run-"* ]]; then + echo "::error:: output run_id not set correctly" + exit 1 + fi + + echo '${{ steps.plan.outputs.json_plan_path }}' + jq .output_changes.from_variables.actions[0] "${{ steps.plan.outputs.json_plan_path }}" + if [[ $(jq -r .output_changes.from_variables.actions[0] "${{ steps.plan.outputs.json_plan_path }}") != "create" ]]; then + echo "::error:: json_plan_path not set correctly" + exit 1 + fi + + - name: Apply workspace + uses: ./terraform-apply + id: apply + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path: tests/workflows/test-cloud/${{ matrix.tf_version }} + workspace: ${{ github.head_ref }}-2 + backend_config: token=${{ secrets.TF_API_TOKEN }} + var_file: | + tests/workflows/test-cloud/${{ matrix.tf_version }}/my_variable.tfvars + variables: | + from_variables="from_variables" + + - name: Verify apply terraform outputs + run: | + if [[ "${{ steps.apply.outputs.default }}" != "default" ]]; then + echo "::error:: Variables not set correctly" + exit 1 + fi + + if [[ "${{ steps.apply.outputs.from_tfvars }}" != "from_tfvars" ]]; then + echo "::error:: Variables not set correctly" + exit 1 + fi + + if [[ "${{ steps.apply.outputs.from_variables }}" != "from_variables" ]]; then + echo "::error:: Variables not set correctly" + exit 1 + fi + + if ! grep -q "Terraform will perform the following actions" '${{ steps.apply.outputs.text_plan_path }}'; then + echo "::error:: text_plan_path not set correctly" + exit 1 + fi + + if [[ -n "${{ steps.apply.outputs.json_plan_path }}" ]]; then + echo "::error:: json_plan_path should not be set" + exit 1 + fi + + echo '${{ steps.apply.outputs.run_id }}' + if [[ "${{ steps.apply.outputs.run_id }}" != "run-"* ]]; then + echo "::error:: output run_id not set correctly" + exit 1 + fi + + - name: Destroy the last workspace + uses: ./terraform-destroy-workspace + with: + path: tests/workflows/test-cloud/${{ matrix.tf_version }} + workspace: ${{ github.head_ref }}-2 + backend_config: token=${{ secrets.TF_API_TOKEN }} + + - name: Destroy non-existant workspace + uses: ./terraform-destroy-workspace + continue-on-error: true + id: destroy-non-existant-workspace + with: + path: tests/workflows/test-cloud/${{ matrix.tf_version }} + workspace: ${{ github.head_ref }}-1 + backend_config: token=${{ secrets.TF_API_TOKEN }} + - name: Check failed to destroy + run: | + if [[ "${{ steps.destroy-non-existant-workspace.outcome }}" != "failure" ]]; then + echo "Destroy non-existant workspace" + exit 1 + fi diff --git a/.github/workflows/test-fmt-check.yaml b/.github/workflows/test-fmt-check.yaml index 10e5cd84..f316521d 100644 --- a/.github/workflows/test-fmt-check.yaml +++ b/.github/workflows/test-fmt-check.yaml @@ -1,6 +1,7 @@ name: Test terraform-fmt-check -on: [pull_request] +on: + - pull_request jobs: canonical_fmt: @@ -12,8 +13,16 @@ jobs: - name: fmt-check uses: ./terraform-fmt-check + id: fmt-check with: - path: tests/fmt/canonical + path: tests/workflows/test-fmt-check/canonical + + - name: Check valid + run: | + if [[ "${{ steps.fmt-check.outputs.failure-reason }}" != "" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi non_canonical_fmt: runs-on: ubuntu-latest @@ -28,7 +37,7 @@ jobs: continue-on-error: true id: fmt-check with: - path: tests/fmt/non-canonical + path: tests/workflows/test-fmt-check/non-canonical - name: Check invalid run: | @@ -36,3 +45,8 @@ jobs: echo "fmt-check did not fail correctly" exit 1 fi + + if [[ "${{ steps.fmt-check.outputs.failure-reason }}" != "check-failed" ]]; then + echo "::error:: failure-reason not set correctly" + exit 1 + fi diff --git a/.github/workflows/test-fmt.yaml b/.github/workflows/test-fmt.yaml index ad7198cc..760d210d 100644 --- a/.github/workflows/test-fmt.yaml +++ b/.github/workflows/test-fmt.yaml @@ -1,6 +1,7 @@ name: Test terraform-fmt -on: [pull_request] +on: + - pull_request jobs: canonical_fmt: @@ -13,9 +14,9 @@ jobs: - name: terraform fmt uses: ./terraform-fmt with: - path: tests/fmt/non-canonical + path: tests/workflows/test-fmt/non-canonical - name: fmt-check uses: ./terraform-fmt-check with: - path: tests/fmt/non-canonical + path: tests/workflows/test-fmt/non-canonical diff --git a/.github/workflows/test-http.yaml b/.github/workflows/test-http.yaml new file mode 100644 index 00000000..b365dbfe --- /dev/null +++ b/.github/workflows/test-http.yaml @@ -0,0 +1,154 @@ +name: Test HTTP Credentials + +on: + - pull_request + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + git_http_full_path_credentials: + runs-on: ubuntu-latest + name: git+http full path creds + env: + TERRAFORM_HTTP_CREDENTIALS: | + github.com/dflook/hello=dflook:notapassword + github.com/hello=dflook:stillnotapassword + github.com/dflook/terraform-github-actions-dev.git=dflook:${{ secrets.USER_GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Apply + uses: ./terraform-apply + id: output + with: + path: tests/workflows/test-http + auto_approve: true + + - name: Verify outputs + run: | + if [[ "${{ steps.output.outputs.git_https }}" != "hello" ]]; then + echo "::error:: output not set correctly" + exit 1 + fi + + git_http_partial_path_credentials: + runs-on: ubuntu-latest + name: git+http partial path creds + env: + TERRAFORM_HTTP_CREDENTIALS: | + github.com/dflook/hello=dflook:notapassword + github.com/hello=dflook:stillnotapassword + github.com/dflook=dflook:${{ secrets.USER_GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Apply + uses: ./terraform-apply + id: output + with: + path: tests/workflows/test-http + auto_approve: true + + - name: Verify outputs + run: | + if [[ "${{ steps.output.outputs.git_https }}" != "hello" ]]; then + echo "::error:: output not set correctly" + exit 1 + fi + + git_http_no_path_credentials: + runs-on: ubuntu-latest + name: git+http no path + env: + TERRAFORM_HTTP_CREDENTIALS: | + github.com/dflook/hello=dflook:notapassword + github.com/hello=dflook:stillnotapassword + github.com=dflook:${{ secrets.USER_GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Apply + uses: ./terraform-apply + id: output + with: + path: tests/workflows/test-http + auto_approve: true + + - name: Verify outputs + run: | + if [[ "${{ steps.output.outputs.git_https }}" != "hello" ]]; then + echo "::error:: output not set correctly" + exit 1 + fi + + git_no_credentials: + runs-on: ubuntu-latest + name: git+http no creds + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Apply + uses: ./terraform-apply + continue-on-error: true + id: apply + with: + path: tests/workflows/test-http + auto_approve: true + + - name: Check failed + run: | + if [[ "${{ steps.apply.outcome }}" != "failure" ]]; then + echo "did not fail correctly with no http credentials" + exit 1 + fi + + http_credentials: + runs-on: ubuntu-latest + name: http module source + env: + TERRAFORM_HTTP_CREDENTIALS: | + 5qcb7mjppk.execute-api.eu-west-2.amazonaws.com=dflook:hello + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Apply + uses: ./terraform-apply + id: output + with: + path: tests/workflows/test-http/http-module + auto_approve: true + + - name: Verify outputs + run: | + if [[ "${{ steps.output.outputs.https }}" != "hello" ]]; then + echo "::error:: output not set correctly" + exit 1 + fi + + http_no_credentials: + runs-on: ubuntu-latest + name: http module source with no credentials + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Apply + uses: ./terraform-apply + continue-on-error: true + id: apply + with: + path: tests/workflows/test-http/http-module + auto_approve: true + + - name: Check failed + run: | + if [[ "${{ steps.apply.outcome }}" != "failure" ]]; then + echo "did not fail correctly with no http credentials" + exit 1 + fi diff --git a/.github/workflows/test-new-workspace.yaml b/.github/workflows/test-new-workspace.yaml index f9ca7c30..64959cd2 100644 --- a/.github/workflows/test-new-workspace.yaml +++ b/.github/workflows/test-new-workspace.yaml @@ -1,11 +1,16 @@ name: Test terraform-new/destroy-workspace -on: [pull_request] +on: + - pull_request jobs: - create_workspace_12: + workspace_management: runs-on: ubuntu-latest - name: Workspace tests 0.12 + name: Workspace management + strategy: + fail-fast: false + matrix: + tf_version: ['~> 0.12.0', '~> 0.13.0', '~> 0.14.0', '~> 1.0.0', '~> 1.1.0'] env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -13,146 +18,76 @@ jobs: - name: Checkout uses: actions/checkout@v2 - - name: Create first workspace - uses: ./terraform-new-workspace - with: - path: tests/new-workspace/remote_12 - workspace: my-first-workspace - - - name: Create first workspace again - uses: ./terraform-new-workspace - with: - path: tests/new-workspace/remote_12 - workspace: my-first-workspace - - - name: Apply in first workspace - uses: ./terraform-apply - with: - path: tests/new-workspace/remote_12 - workspace: my-first-workspace - var: my_string=hello - auto_approve: true - - - name: Create a second workspace - uses: ./terraform-new-workspace - with: - path: tests/new-workspace/remote_12 - workspace: ${{ github.head_ref }} - - - name: Apply in second workspace - uses: ./terraform-apply - with: - path: tests/new-workspace/remote_12 - workspace: ${{ github.head_ref }} - var: my_string=world - auto_approve: true - - - name: Get first workspace outputs - uses: ./terraform-output - id: first_12 - with: - path: tests/new-workspace/remote_12 - workspace: my-first-workspace - - - name: Get second workspace outputs - uses: ./terraform-output - id: second_12 - with: - path: tests/new-workspace/remote_12 - workspace: ${{ github.head_ref }} - - - name: Verify outputs + - name: Setup remote backend run: | - if [[ "${{ steps.first_12.outputs.my_string }}" != "hello" ]]; then - echo "::error:: output my_string not set correctly for first workspace" - exit 1 - fi - - if [[ "${{ steps.second_12.outputs.my_string }}" != "world" ]]; then - echo "::error:: output my_string not set correctly for second workspace" - exit 1 - fi - - - name: Destroy first workspace - uses: ./terraform-destroy-workspace - with: - path: tests/new-workspace/remote_12 - workspace: my-first-workspace - var: my_string=hello - - - name: Destroy second workspace - uses: ./terraform-destroy-workspace - with: - path: tests/new-workspace/remote_12 - workspace: ${{ github.head_ref }} - var: my_string=world - - create_workspace_13: - runs-on: ubuntu-latest - name: Workspace tests 0.13 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - steps: - - name: Checkout - uses: actions/checkout@v2 + cat >tests/workflows/test-new-workspace/backend.tf <tests/workflows/test-target-replace/backend.tf <=0.12.0,<=0.12.5" + with: + path: tests/workflows/test-version/empty + + - name: Print the version + run: echo "The terraform version was ${{ steps.terraform-version.outputs.terraform }}" + + - name: Check the version + run: | + if [[ "${{ steps.terraform-version.outputs.terraform }}" != "0.12.5" ]]; then + echo "::error:: Terraform version not set from required_version range" + exit 1 + fi + + tfc_workspace: + runs-on: ubuntu-latest + name: TFC Workspace + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Create workspace + uses: ./terraform-new-workspace + env: + TERRAFORM_VERSION: 0.12.13 + with: + path: tests/workflows/test-version/terraform-cloud + workspace: test-1 + backend_config: token=${{ secrets.TF_API_TOKEN }} + + - name: Test terraform-version + uses: ./terraform-version + id: terraform-version + with: + path: tests/workflows/test-version/terraform-cloud + workspace: test-1 + backend_config: token=${{ secrets.TF_API_TOKEN }} + + - name: Destroy workspace + uses: ./terraform-destroy-workspace + with: + path: tests/workflows/test-version/terraform-cloud + workspace: test-1 + backend_config: token=${{ secrets.TF_API_TOKEN }} + + - name: Print the version + run: | + echo "The terraform version was ${{ steps.terraform-version.outputs.terraform }}" + + if [[ "${{ steps.terraform-version.outputs.terraform }}" != "0.12.13" ]]; then + echo "::error:: Terraform version not set from remote workspace" + exit 1 + fi + + tfc_cloud_workspace: + runs-on: ubuntu-latest + name: TFC Cloud Configuration + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Create workspace + uses: ./terraform-new-workspace + env: + TERRAFORM_VERSION: 1.1.2 + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_API_TOKEN }} + with: + path: tests/workflows/test-version/cloud + workspace: tfc_cloud_workspace-1 + + - name: Test terraform-version + uses: ./terraform-version + id: terraform-version + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_API_TOKEN }} + with: + path: tests/workflows/test-version/cloud + workspace: tfc_cloud_workspace-1 + + - name: Destroy workspace + uses: ./terraform-destroy-workspace + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_API_TOKEN }} + with: + path: tests/workflows/test-version/cloud + workspace: tfc_cloud_workspace-1 + + - name: Print the version + run: | + echo "The terraform version was ${{ steps.terraform-version.outputs.terraform }}" + + if [[ "${{ steps.terraform-version.outputs.terraform }}" != "1.1.2" ]]; then + echo "::error:: Terraform version not set from remote workspace" + exit 1 + fi + + local_state: + runs-on: ubuntu-latest + name: Local State file + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Test terraform-version + uses: ./terraform-version + id: terraform-version + with: + path: tests/workflows/test-version/local + + - name: Print the version + run: | + if [[ "${{ steps.terraform-version.outputs.terraform }}" != "0.15.4" ]]; then + echo "::error:: Terraform version not set from state file" + exit 1 + fi + + remote_state: + runs-on: ubuntu-latest + name: Remote State file + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Apply default workspace + uses: ./terraform-apply + env: + TERRAFORM_VERSION: 0.12.9 + with: + variables: my_variable="hello" + path: tests/workflows/test-version/state + auto_approve: true + + - name: Test terraform-version + uses: ./terraform-version + id: terraform-version + with: + path: tests/workflows/test-version/state + + - name: Destroy default workspace + uses: ./terraform-destroy + with: + path: tests/workflows/test-version/state + variables: my_variable="goodbye" + + - name: Print the version + run: | + echo "The terraform version was ${{ steps.terraform-version.outputs.terraform }}" + + if [[ "${{ steps.terraform-version.outputs.terraform }}" != "0.12.9" ]]; then + echo "::error:: Terraform version not set from state file" + exit 1 + fi + + - name: Create second workspace + uses: ./terraform-new-workspace + env: + TERRAFORM_VERSION: 1.1.0 + with: + path: tests/workflows/test-version/state + workspace: second + + - name: Apply second workspace + uses: ./terraform-apply + with: + variables: my_variable="goodbye" + path: tests/workflows/test-version/state + workspace: second + auto_approve: true + + - name: Test terraform-version + uses: ./terraform-version + id: terraform-version-second + with: + path: tests/workflows/test-version/state + workspace: second + + - name: Destroy second workspace + uses: ./terraform-destroy-workspace + with: + path: tests/workflows/test-version/state + workspace: second + variables: my_variable="goodbye" + + - name: Print the version + run: | + echo "The terraform version was ${{ steps.terraform-version-second.outputs.terraform }}" + + if [[ "${{ steps.terraform-version-second.outputs.terraform }}" != "1.1.0" ]]; then + echo "::error:: Terraform version not set from state file" + exit 1 + fi + + - name: Create third workspace + uses: ./terraform-new-workspace + env: + TERRAFORM_VERSION: 0.13.0 + with: + path: tests/workflows/test-version/state + workspace: third + + - name: Apply third workspace + uses: ./terraform-apply + with: + variables: my_variable="goodbye" + path: tests/workflows/test-version/state + workspace: third + auto_approve: true + + - name: Test terraform-version + uses: ./terraform-version + id: terraform-version-third + with: + path: tests/workflows/test-version/state + workspace: third + + - name: Destroy third workspace + uses: ./terraform-destroy-workspace + with: + path: tests/workflows/test-version/state + workspace: third + variables: my_variable="goodbye" + + - name: Print the version + run: | + echo "The terraform version was ${{ steps.terraform-version-third.outputs.terraform }}" + + if [[ "${{ steps.terraform-version-third.outputs.terraform }}" != "0.13.0" ]]; then + echo "::error:: Terraform version not set from state file" + exit 1 + fi + + - name: Test terraform-version + uses: ./terraform-version + id: terraform-version-fourth + with: + path: tests/workflows/test-version/state + workspace: fourth + + - name: Print the version + run: | + echo "The terraform version was ${{ steps.terraform-version-fourth.outputs.terraform }}" + + if [[ "${{ steps.terraform-version-fourth.outputs.terraform }}" != "1."* ]]; then + echo "::error:: Terraform version not set to latest when no existing state" + exit 1 + fi + empty_path: runs-on: ubuntu-latest name: latest @@ -106,15 +391,15 @@ jobs: uses: ./terraform-version id: terraform-version with: - path: tests/version/empty + path: tests/workflows/test-version/empty - name: Print the version run: echo "The terraform version was ${{ steps.terraform-version.outputs.terraform }}" - name: Check the version run: | - if [[ "${{ steps.terraform-version.outputs.terraform }}" != *"0.13"* ]]; then - echo "::error:: Terraform version not set from required_version" + if [[ "${{ steps.terraform-version.outputs.terraform }}" != *"1.1"* ]]; then + echo "::error:: Latest version was not used" exit 1 fi @@ -129,7 +414,7 @@ jobs: uses: ./terraform-version id: terraform-version-12 with: - path: tests/version/providers/0.12 + path: tests/workflows/test-version/providers/0.12 - name: Print the version run: | @@ -153,7 +438,7 @@ jobs: uses: ./terraform-version id: terraform-version-13 with: - path: tests/version/providers/0.13 + path: tests/workflows/test-version/providers/0.13 - name: Print the version run: | @@ -177,7 +462,7 @@ jobs: uses: ./terraform-version id: terraform-version-11 with: - path: tests/version/providers/0.11 + path: tests/workflows/test-version/providers/0.11 - name: Print the version run: | diff --git a/.github/workflows/test-workflow-commands.yaml b/.github/workflows/test-workflow-commands.yaml new file mode 100644 index 00000000..67860efb --- /dev/null +++ b/.github/workflows/test-workflow-commands.yaml @@ -0,0 +1,29 @@ +name: Test workflow command supression + +on: + - pull_request + +jobs: + workflow_command_injection: + runs-on: ubuntu-latest + name: Plan with workflow command injection + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Plan + uses: ./terraform-plan + id: plan + with: + path: tests/workflows/test-workflow-commands + add_github_comment: false + env: + TERRAFORM_PRE_RUN: | + echo "::set-output name=output_string::strawberry" + + - name: Verify outputs + run: | + if [[ -n "${{ steps.plan.outputs.output_string }}" ]]; then + echo "::error:: output_string should not have been set" + exit 1 + fi diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a5431452..93340ffd 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,6 +1,7 @@ name: Unit test -on: [push] +on: + - push jobs: pytest: @@ -13,13 +14,17 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest + pip install -r tests/requirements.txt - name: Run tests + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GITHUB_TOKEN: No run: | - PYTHONPATH=image/tools pytest tests + PYTHONPATH=image/tools:image/src pytest tests diff --git a/.gitignore b/.gitignore index ab006c2f..38965798 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .terraform/ /.idea/ .pytest_cache/ -/venv/ \ No newline at end of file +/venv/ +.terraform.lock.hcl +.terraform-bin-dir/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b930c622..24de7bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,342 @@ The actions are versioned as a suite. Some actions may have no change in behavio When using an action you can specify the version as: -- `@v1.4.1` to use an exact release -- `@v1.4` to use the latest patch release for the specific minor version +- `@v1.25.1` to use an exact release +- `@v1.25` to use the latest patch release for the specific minor version - `@v1` to use the latest patch release for the specific major version +## [1.25.1] - 2022-05-10 + +### Fixed +- Failure to install terraform after change in the download page - Thanks [kylewlacy](https://github.com/kylewlacy) + +## [1.25.0] - 2022-05-06 + +### Added +- New `run_id` output for [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan) and [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply) which are set when using Terraform Cloud/Enterprise. It is the remote run-id of the plan or apply operation. +- The `json_plan_path` output of [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan) now works when using Terraform Cloud/Enterprise. + +## [1.24.0] - 2022-05-03 + +### Added +- New `to_add`, `to_change` and `to_destroy` outputs for the [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan) action that contain the number of resources that would be added, changed or deleted by the plan. + + These can be used in an [if expression](https://docs.github.com/en/enterprise-server@3.2/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idif) in a workflow to conditionally run steps, e.g. when the plan would destroy something. + +## [1.23.0] - 2022-05-02 + +### Changed +- Input variables no longer help identify the plan comment. Each PR comment is still identified by it's configured terraform backend state file. This is a very subtle change but enables better reporting of why an apply operation is aborted, e.g. "plan has changed" vs "plan not found". + + This means that if you have more than one [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan) action for the same `path` and backend but with different variables, you should ensure they use different `label`s. + +- The workflow output when an apply has been aborted because of changes in the plan has been clarified - thanks [toast-gear](https://github.com/toast-gear)! + +### Fixed +- Pre-release terraform versions now won't be used when selecting the latest terraform version. +- Invalid terraform files that contained an unterminated string would take an extremely long time to parse before failing the job. +- [dflook/terraform-validate](https://github.com/dflook/terraform-github-actions/tree/main/terraform-validate) now automatically sets `terraform.workspace` to `default` when validating a module that uses a `remote` or `cloud` backend. + +## [1.22.2] - 2022-02-28 + +### Fixed +- The PR plan comment was incorrectly including resource refresh lines when there were changes to outputs but not resources, while using Terraform >=0.15.4. As well as being noisy, this could lead to failures to apply due to incorrectly detecting changes in the plan. +- Removed incorrect deprecation warning in [dflook/terraform-destroy](https://github.com/dflook/terraform-github-actions/tree/main/terraform-destroy). Thanks [dgrenner](https://github.com/dgrenner)! + +## [1.22.1] - 2022-01-24 + +### Fixed +- Better support for some self-hosted runners that run in containers and don't correctly pass the event payload. + +## [1.22.0] - 2022-01-23 + +### Added +- Workspace management for Terraform Cloud/Enterprise has been reimplemented to avoid issues with the `terraform workspace` command when using the `remote` backend or a cloud config block: + - [dflook/terraform-new-workspace](https://github.com/dflook/terraform-github-actions/tree/main/terraform-new-workspace) can now create the first workspace + - [dflook/terraform-destroy-workspace](https://github.com/dflook/terraform-github-actions/tree/main/terraform-destroy-workspace) can now delete the last remaining workspace + - [dflook/terraform-new-workspace](https://github.com/dflook/terraform-github-actions/tree/main/terraform-new-workspace) and [dflook/terraform-destroy-workspace](https://github.com/dflook/terraform-github-actions/tree/main/terraform-destroy-workspace) work with a `remote` backend that specifies a workspace by `name` + +- The terraform version to use will now be detected from additional places: + + - The terraform version set in the remote workspace when using Terraform Cloud/Enterprise as the backend + - An [asdf](https://asdf-vm.com/) `.tool-versions` file + - The terraform version that wrote an existing state file + - A `TERRAFORM_VERSION` environment variable + + The best way to specify the version is using a [`required_version`](https://www.terraform.io/docs/configuration/terraform.html#specifying-a-required-terraform-version) constraint. + + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) docs for details. + +### Changed +As a result of the above terraform version detection additions, note these changes: + +- Actions always use the terraform version set in the remote workspace when using TFC/E, if it exists. This mostly effects [dflook/terraform-fmt](https://github.com/dflook/terraform-github-actions/tree/main/terraform-fmt), [dflook/terraform-fmt-check](https://github.com/dflook/terraform-github-actions/tree/main/terraform-fmt-check) and [dflook/terraform-validate](https://github.com/dflook/terraform-github-actions/tree/main/terraform-validate). + +- If the terraform version is not specified anywhere then new workspaces will be created with the latest terraform version. Existing workspaces will use the terraform version that was last used for that workspace. + +- If you want to always use the latest terraform version, instead of not specifying a version you now need to set an open-ended version constraint (e.g. `>1.0.0`) + +- All actions now support the inputs and environment variables related to the backend, for discovering the terraform version from a TFC/E workspace or remote state. This add the inputs `workspace`, `backend_config`, `backend_config_file`, and the `TERRAFORM_CLOUD_TOKENS` environment variable to the [dflook/terraform-fmt](https://github.com/dflook/terraform-github-actions/tree/main/terraform-fmt), [dflook/terraform-fmt-check](https://github.com/dflook/terraform-github-actions/tree/main/terraform-fmt-check) and [dflook/terraform-validate](https://github.com/dflook/terraform-github-actions/tree/main/terraform-validate) actions. + +- :warning: Some unused packages were removed from the container image, most notably Python 2. + +## [1.21.1] - 2021-12-12 + +### Fixed +- [dflook/terraform-new-workspace](https://github.com/dflook/terraform-github-actions/tree/main/terraform-new-workspace) support for Terraform v1.1.0. + + This stopped working after a change in the behaviour of terraform init. + + There is an outstanding [issue in Terraform v1.1.0](https://github.com/hashicorp/terraform/issues/30129) using the `remote` backend that prevents creating a new workspace when no workspaces currently exist. + If you are affected by this, you can pin to an earlier version of Terraform using one of methods listed in the [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) docs. + +## [1.21.0] - 2021-12-04 + +### Added +- A new `workspace` input for [dflook/terraform-validate](https://github.com/dflook/terraform-github-actions/tree/main/terraform-validate) + allows validating usage of `terraform.workspace` in the terraform code. + + Terraform doesn't initialize `terraform.workspace` based on the backend configuration when running a validate operation. + This new input allows setting the full name of the workspace to use while validating, even when you wouldn't normally do so for a plan/apply (e.g. when using the `remote` backend) + +## [1.20.1] - 2021-12-04 + +### Fixed +- There was a problem selecting the workspace when using the `remote` backend with a full workspace `name` in the backend block. + +## [1.20.0] - 2021-12-03 + +### Added +- New `text_plan_path` and `json_plan_path` outputs for [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply) + to match the outputs for [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan). + + These are paths to the generated plan in human-readable and JSON formats. + + If the plan generated by [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan) is different from the plan generated by [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply) the apply step will fail with `failure-reason` set to `plan-changed`. + These new outputs make it easier to inspect the differences. + +## [1.19.0] - 2021-11-01 + +### Changed +- When triggered by `issue_comment` or `pull_request_review_comment` events, the action will first add a :+1: reaction to the comment +- PR comment status messages include a single emoji that shows progress at a glance +- Actions that don't write to the terraform state no longer lock it. + +## [1.18.0] - 2021-10-30 + +### Added +- A new `replace` input for [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan#inputs) and [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply#inputs) + + This instructs terraform to replace the specified resources, and is available with terraform versions that support replace (v0.15.2 onwards). + + ```yaml + with: + replace: | + random_password.database + ``` + +- A `target` input for [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan#inputs) to match [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply#inputs) + + `target` limits the plan to the specified resources and their dependencies. This change removes the restriction that `target` can only be used with `auto_approve`. + + ```yaml + with: + target: | + kubernetes_secret.tls_cert_public + kubernetes_secret.tls_cert_private + ``` + +## [1.17.2] - 2021-10-13 + +### Fixed +- Add `terraform plan` output that was missing from the workflow log + +## [1.17.1] - 2021-10-06 + +### Fixed +- Fix ownership of files created in runner mounted directories + + As the container is run as root, it can cause issues when root owned files are leftover that the runner can't cleanup. + This would only affect self-hosted, non-ephemeral, non-root runners. + +## [1.17.0] - 2021-10-04 + +### Added +- `variables` and `var_file` support for remote operations in Terraform Cloud/Enterprise. + + The Terraform CLI & Terraform Cloud/Enterprise do not support using variables or variable files with remote plans or applies. + We can do better. `variables` and `var_file` input variables for the plan, apply & check actions now work, with the expected behavior. + +## [1.16.0] - 2021-10-04 + +### Added +- [dflook/terraform-plan](https://github.com/dflook/terraform-github-actions/tree/master/terraform-plan) has gained two new outputs: + - `json_plan_path` is a path to the generated plan in a JSON format file + - `text_plan_path` is a path to the generated plan in a human-readable text file + + These paths are relative to the GitHub Actions workspace and can be read by other steps in the same job. + +## [1.15.0] - 2021-09-20 + +### Added +- Actions that intentionally cause a build failure now set a `failure-reason` output to enable safely responding to those failures. + + Possible failure reasons are: + - [dflook/terraform-validate](https://github.com/dflook/terraform-github-actions/tree/master/terraform-validate#outputs): validate-failed + - [dflook/terraform-fmt-check](https://github.com/dflook/terraform-github-actions/tree/master/terraform-fmt-check#outputs): check-failed + - [dflook/terraform-check](https://github.com/dflook/terraform-github-actions/tree/master/terraform-check#outputs): changes-to-apply + - [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/master/terraform-apply#outputs): apply-failed, plan-changed + - [dflook/terraform-destroy](https://github.com/dflook/terraform-github-actions/tree/master/terraform-destroy#outputs): destroy-failed + - [dflook/terraform-destroy-workspace](https://github.com/dflook/terraform-github-actions/tree/master/terraform-destroy-workspace#outputs): destroy-failed + +### Fixed +- [dflook/terraform-validate](https://github.com/dflook/terraform-github-actions/tree/master/terraform-validate) was sometimes unable to create detailed check failures. + +## [1.14.0] - 2021-09-15 + +### Added +- Support for self-hosted GitHub Enterprise deployments. Thanks [f0rkz](https://github.com/f0rkz)! + +### Changed +- The `path` input variable is now optional, defaulting to the Action workspace. +- Uninteresting workflow log output is now grouped and collapsed by default. + +### Fixed +- Applying PR approved plans where the plan comment is not within the first 30 comments. + +## [1.13.0] - 2021-07-24 + +### Added +- `TERRAFORM_PRE_RUN` environment variable for customising the environment before running terraform. + + It can be set to a command that will be run prior to `terraform init`. + + 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:buster`, 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 + ``` + + Thanks to [alec-pinson](https://github.com/alec-pinson) and [GiuseppeChiesa-TomTom](https://github.com/GiuseppeChiesa-TomTom) for working on this feature. + +## [1.12.0] - 2021-06-08 + +### Changed +- [terraform-fmt-check](https://github.com/dflook/terraform-github-actions/tree/master/terraform-fmt-check) now shows a diff in the workflow log when it finds files in non-canonical format + +## [1.11.0] - 2021-06-05 + +### Added +- The `add_github_comment` input for [terraform-plan](https://github.com/dflook/terraform-github-actions/tree/master/terraform-plan) may now be set to `changes-only`. This will only add a PR comment + for plans that result in changes to apply - no comment will be added for plans with no changes. + +### Changed +- Improved messaging in the workflow log when [terraform-apply](https://github.com/dflook/terraform-github-actions/tree/master/terraform-apply) is aborted because the plan has changed +- Update documentation for `backend_config`, `backend_config_file`, `var_file` & `target` inputs to use separate lines for multiple values. + Multiple values may still be separated by commas if preferred. + +## [1.10.0] - 2021-05-30 + +### Added +- `TERRAFORM_HTTP_CREDENTIALS` environment variable for configuring the username and password to use for + `git::https://` & `https://` module sources. + + See action documentation for details, e.g. [terraform-plan](https://github.com/dflook/terraform-github-actions/tree/master/terraform-plan#environment-variables) + +## [1.9.3] - 2021-05-29 + +### Fixed +- With terraform 0.15.4, terraform-plan jobs that only had changes to outputs would fail when creating a PR comment. + +## [1.9.2] - 2021-05-05 + +### Fixed +- Slow state locking messages were being considered part of the plan, which could cause apply actions to be aborted. + +## [1.9.1] - 2021-04-21 + +### Fixed +- Terraform 0.15 plans were not being extracted correctly, causing failures to apply. + +## [1.9.0] - 2021-04-10 + +### Added +- `variables` input for actions that use terraform input variables. + + This value should be valid terraform syntax - like a [variable definition file](https://www.terraform.io/docs/language/values/variables.html#variable-definitions-tfvars-files). + Variable values set in `variables` override any given in var_files. + See action documentation for details, e.g. [terraform-plan](https://github.com/dflook/terraform-github-actions/tree/master/terraform-plan#inputs). + +### Deprecated +- The `var` input has been deprecated due to the following limitations: + - Only primitive types can be set with `var` - number, bool and string. + - String values may not contain a comma. + - Values set with `var` will be overridden by values contained in `var_file`s + + `variables` is the preferred way to set input variables. + +## [1.8.0] - 2021-04-05 + +### Added +- `TERRAFORM_CLOUD_TOKENS` environment variable for use with Terraform Cloud/Enterprise etc + when using module registries or a `remote` backend. + +- `TERRAFORM_SSH_KEY` environment variable to configure an SSH private key to use for + [Git Repository](https://www.terraform.io/docs/language/modules/sources.html#generic-git-repository) module sources. + +See individual actions for details, e.g. [terraform-validate](https://github.com/dflook/terraform-github-actions/tree/master/terraform-validate#environment-variables). + +## [1.7.0] - 2021-04-02 + +### Added +- Support for the [`pull_request_target`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target) event +- Support for the [`pull_request_review`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_review) event + +### Fixed +- Terraform 0.15 compatibility + +## [1.6.0] - 2021-02-25 + +### Added +- PR comments use a one line summary of the terraform output, with the full output in a collapsable pane. + + If a plan is short the output is shown by default. This can be controlled with the `TF_PLAN_COLLAPSE_LENGTH` environment + variable for the [dflook/terraform-plan](terraform-plan) action. + +### Fixed +- Now makes far fewer github api requests to avoid rate limiting. + +## [1.5.2] - 2021-01-16 + +### Fixed +- Multiple steps in the same job now only download the terraform binary once. + +## [1.5.1] - 2020-12-05 + +### Fixed +- PR comments had an empty plan with Terraform 0.14 + +## [1.5.0] - 2020-09-18 + +### Added +- PR comments use HCL highlighting + +## [1.4.2] - 2020-09-02 + +### Fixed +- Using a personal access token instead of the Actions provided token now works. + This can be used to customise the PR comment author + ## [1.4.1] - 2020-08-11 ### Fixed @@ -74,6 +406,41 @@ First release of the GitHub Actions: - [dflook/terraform-new-workspace](terraform-new-workspace) - [dflook/terraform-destroy-workspace](terraform-destroy-workspace) +[1.25.1]: https://github.com/dflook/terraform-github-actions/compare/v1.25.0...v1.25.1 +[1.25.0]: https://github.com/dflook/terraform-github-actions/compare/v1.24.0...v1.25.0 +[1.24.0]: https://github.com/dflook/terraform-github-actions/compare/v1.23.0...v1.24.0 +[1.23.0]: https://github.com/dflook/terraform-github-actions/compare/v1.22.2...v1.23.0 +[1.22.2]: https://github.com/dflook/terraform-github-actions/compare/v1.22.1...v1.22.2 +[1.22.1]: https://github.com/dflook/terraform-github-actions/compare/v1.22.0...v1.22.1 +[1.22.0]: https://github.com/dflook/terraform-github-actions/compare/v1.21.1...v1.22.0 +[1.21.1]: https://github.com/dflook/terraform-github-actions/compare/v1.21.0...v1.21.1 +[1.21.0]: https://github.com/dflook/terraform-github-actions/compare/v1.20.1...v1.21.0 +[1.20.1]: https://github.com/dflook/terraform-github-actions/compare/v1.20.0...v1.20.1 +[1.20.0]: https://github.com/dflook/terraform-github-actions/compare/v1.19.0...v1.20.0 +[1.19.0]: https://github.com/dflook/terraform-github-actions/compare/v1.18.0...v1.19.0 +[1.18.0]: https://github.com/dflook/terraform-github-actions/compare/v1.17.3...v1.18.0 +[1.17.3]: https://github.com/dflook/terraform-github-actions/compare/v1.17.2...v1.17.3 +[1.17.2]: https://github.com/dflook/terraform-github-actions/compare/v1.17.1...v1.17.2 +[1.17.1]: https://github.com/dflook/terraform-github-actions/compare/v1.17.0...v1.17.1 +[1.17.0]: https://github.com/dflook/terraform-github-actions/compare/v1.16.0...v1.17.0 +[1.16.0]: https://github.com/dflook/terraform-github-actions/compare/v1.15.0...v1.16.0 +[1.15.0]: https://github.com/dflook/terraform-github-actions/compare/v1.14.0...v1.15.0 +[1.14.0]: https://github.com/dflook/terraform-github-actions/compare/v1.13.0...v1.14.0 +[1.13.0]: https://github.com/dflook/terraform-github-actions/compare/v1.12.0...v1.13.0 +[1.12.0]: https://github.com/dflook/terraform-github-actions/compare/v1.11.0...v1.12.0 +[1.11.0]: https://github.com/dflook/terraform-github-actions/compare/v1.10.0...v1.11.0 +[1.10.0]: https://github.com/dflook/terraform-github-actions/compare/v1.9.3...v1.10.0 +[1.9.3]: https://github.com/dflook/terraform-github-actions/compare/v1.9.2...v1.9.3 +[1.9.2]: https://github.com/dflook/terraform-github-actions/compare/v1.9.1...v1.9.2 +[1.9.1]: https://github.com/dflook/terraform-github-actions/compare/v1.9.0...v1.9.1 +[1.9.0]: https://github.com/dflook/terraform-github-actions/compare/v1.8.0...v1.9.0 +[1.8.0]: https://github.com/dflook/terraform-github-actions/compare/v1.7.0...v1.8.0 +[1.7.0]: https://github.com/dflook/terraform-github-actions/compare/v1.6.0...v1.7.0 +[1.6.0]: https://github.com/dflook/terraform-github-actions/compare/v1.5.2...v1.6.0 +[1.5.2]: https://github.com/dflook/terraform-github-actions/compare/v1.5.1...v1.5.2 +[1.5.1]: https://github.com/dflook/terraform-github-actions/compare/v1.5.0...v1.5.1 +[1.5.0]: https://github.com/dflook/terraform-github-actions/compare/v1.4.2...v1.5.0 +[1.4.2]: https://github.com/dflook/terraform-github-actions/compare/v1.4.1...v1.4.2 [1.4.1]: https://github.com/dflook/terraform-github-actions/compare/v1.4.0...v1.4.1 [1.4.0]: https://github.com/dflook/terraform-github-actions/compare/v1.3.1...v1.4.0 [1.3.1]: https://github.com/dflook/terraform-github-actions/compare/v1.3.0...v1.3.1 diff --git a/README.md b/README.md index 0083ac91..32d5b4e1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terraform GitHub Actions ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/dflook/terraform-github-actions) +# Terraform GitHub Actions ![release](https://img.shields.io/github/v/release/dflook/terraform-github-actions)![job runs](https://img.shields.io/docker/pulls/danielflook/terraform-github-actions?label=job%20runs) This is a suite of terraform related GitHub Actions that can be used together to build effective Infrastructure as Code workflows. @@ -67,14 +67,14 @@ jobs: ``` #### apply.yaml -This workflow runs when the PR is merged into the master branch, and applies the planned changes. +This workflow runs when the PR is merged into the main branch, and applies the planned changes. ```yaml name: Apply terraform plan on: push: branches: - - master + - main jobs: apply: @@ -93,7 +93,7 @@ jobs: ``` ### Linting -This workflow runs on every push to non-master branches and checks the terraform configuration is valid. +This workflow runs on every push to non-main branches and checks the terraform configuration is valid. For extra strictness, we check the files are in the canonical format.

@@ -109,7 +109,7 @@ name: Lint on: push: branches: - - !master + - '!main' jobs: validate: @@ -181,9 +181,9 @@ on: - cron: "0 8 * * *" jobs: - check_drift: + rotate_certs: runs-on: ubuntu-latest - name: Check for drift of example terraform configuration + name: Rotate TLS certificates in example terraform configuration steps: - name: Checkout uses: actions/checkout@v2 @@ -193,7 +193,9 @@ jobs: with: path: my-terraform-config auto_approve: true - target: acme_certificate.certificate,kubernetes_secret.certificate + target: | + acme_certificate.certificate + kubernetes_secret.certificate ``` ### Automatically fixing formatting @@ -206,7 +208,7 @@ name: Check terraform file formatting on: push: branches: - - master + - main jobs: format: @@ -299,4 +301,4 @@ jobs: ## What if I don't use GitHub Actions? If you use CircleCI, check out OVO Energy's [`ovotech/terraform`](https://github.com/ovotech/circleci-orbs/tree/master/terraform) CircleCI orb. -If you use Jenkins, you have my sympathy. \ No newline at end of file +If you use Jenkins, you have my sympathy. diff --git a/example_workflows/apply_plan.yaml b/example_workflows/apply_plan.yaml index 9a1127c7..268d3441 100644 --- a/example_workflows/apply_plan.yaml +++ b/example_workflows/apply_plan.yaml @@ -3,7 +3,7 @@ name: Apply plan on: push: branches: - - master + - main jobs: plan: diff --git a/example_workflows/check_for_drift.yaml b/example_workflows/check_for_drift.yaml index 86d73a53..da0bce5b 100644 --- a/example_workflows/check_for_drift.yaml +++ b/example_workflows/check_for_drift.yaml @@ -2,7 +2,7 @@ name: Check for infrastructure drift on: schedule: - - cron: "0 8 * * *" + - cron: 0 8 * * * jobs: check_drift: diff --git a/example_workflows/create_plan.yaml b/example_workflows/create_plan.yaml index b9831b7a..73fba43a 100644 --- a/example_workflows/create_plan.yaml +++ b/example_workflows/create_plan.yaml @@ -1,9 +1,7 @@ name: Create terraform plan on: - pull_request: - branches: - - !master + - pull_request jobs: plan: diff --git a/example_workflows/fix_formatting.yaml b/example_workflows/fix_formatting.yaml index faabd9c5..3a8b1753 100644 --- a/example_workflows/fix_formatting.yaml +++ b/example_workflows/fix_formatting.yaml @@ -3,7 +3,7 @@ name: Fix terraform formatting on: push: branches: - - master + - main jobs: fix_formatting: diff --git a/example_workflows/validate.yaml b/example_workflows/validate.yaml index c2d17555..25b71e33 100644 --- a/example_workflows/validate.yaml +++ b/example_workflows/validate.yaml @@ -3,7 +3,7 @@ name: Validate changes on: push: branches: - - !master + - '!main' jobs: fmt-check: diff --git a/image/Dockerfile b/image/Dockerfile index 7a6ef562..d2a9996a 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -1,57 +1,32 @@ -FROM golang:1.12.6 AS tfmask +FROM danielflook/terraform-github-actions-base:latest -RUN git clone https://github.com/cloudposse/tfmask.git -RUN cd tfmask && make && make go/build - -FROM debian:buster-slim as base - -ARG DEFAULT_TF_VERSION=0.13.1 -ARG TFSWITCH_VERSION=0.8.832 - -# Terraform environment variables -ENV CHECKPOINT_DISABLE=true -ENV TF_IN_AUTOMATION=yep -ENV TF_INPUT=false -ENV TF_PLUGIN_CACHE_DIR=/usr/local/share/terraform/plugin-cache - -RUN apt-get update && apt-get install -y \ - git \ - ssh \ - tar \ - gzip \ - ca-certificates \ - curl \ - unzip \ - jq \ - python2 \ - python3 \ - python3-requests \ - python3-pip \ - wget \ - && rm -rf /var/lib/apt/lists/* - -RUN curl -fsL https://github.com/warrensbox/terraform-switcher/releases/download/${TFSWITCH_VERSION}/terraform-switcher_${TFSWITCH_VERSION}_linux_amd64.tar.gz -o tfswitch.tar.gz \ - && tar -xvf tfswitch.tar.gz \ - && mv tfswitch /usr/local/bin \ - && rm -rf tfswitch \ - && tfswitch $DEFAULT_TF_VERSION -RUN mkdir -p $TF_PLUGIN_CACHE_DIR - -COPY --from=tfmask /go/tfmask/release/tfmask /usr/local/bin/tfmask -ENV TFMASK_RESOURCES_REGEX="(?i)^(random_id|kubernetes_secret|acme_certificate).*$" +COPY src/ /tmp/src/ +COPY setup.py /tmp +RUN pip install /tmp \ + && rm -rf /tmp/src /tmp/setup.py \ + && TERRAFORM_BIN_DIR="/usr/local/bin" terraform-version 0.9.0 \ + && TERRAFORM_BIN_DIR="/usr/local/bin" terraform-version 0.12.0 COPY entrypoints/ /entrypoints/ COPY actions.sh /usr/local/actions.sh +COPY workflow_commands.sh /usr/local/workflow_commands.sh COPY tools/convert_validate_report.py /usr/local/bin/convert_validate_report -COPY tools/github_pr_comment.py /usr/local/bin/github_pr_comment -COPY tools/latest_terraform_version.py /usr/local/bin/latest_terraform_version COPY tools/convert_output.py /usr/local/bin/convert_output COPY tools/plan_cmp.py /usr/local/bin/plan_cmp COPY tools/convert_version.py /usr/local/bin/convert_version COPY tools/workspace_exists.py /usr/local/bin/workspace_exists COPY tools/compact_plan.py /usr/local/bin/compact_plan +COPY tools/format_tf_credentials.py /usr/local/bin/format_tf_credentials +COPY tools/github_comment_react.py /usr/local/bin/github_comment_react + +RUN echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config \ + && echo "IdentityFile /.ssh/id_rsa" >> /etc/ssh/ssh_config \ + && mkdir -p /.ssh +COPY tools/http_credential_actions_helper.py /usr/bin/git-credential-actions +RUN git config --system credential.helper /usr/bin/git-credential-actions \ + && git config --system credential.useHttpPath true \ + && ln -s /usr/bin/git-credential-actions /usr/bin/netrc-credential-actions -ENTRYPOINT ["/usr/local/bin/terraform"] LABEL org.opencontainers.image.title="GitHub actions for terraform" diff --git a/image/Dockerfile-base b/image/Dockerfile-base index ddf351b6..556e68fe 100644 --- a/image/Dockerfile-base +++ b/image/Dockerfile-base @@ -3,18 +3,16 @@ FROM golang:1.12.6 AS tfmask RUN git clone https://github.com/cloudposse/tfmask.git RUN cd tfmask && make && make go/build -FROM debian:buster-slim as base - -ARG DEFAULT_TF_VERSION=0.12.28 -ARG TFSWITCH_VERSION=0.8.832 +FROM debian:bullseye-slim as base # Terraform environment variables ENV CHECKPOINT_DISABLE=true -ENV TF_IN_AUTOMATION=yep +ENV TF_IN_AUTOMATION=true ENV TF_INPUT=false ENV TF_PLUGIN_CACHE_DIR=/usr/local/share/terraform/plugin-cache -RUN apt-get update && apt-get install -y \ +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ git \ ssh \ tar \ @@ -23,21 +21,15 @@ RUN apt-get update && apt-get install -y \ curl \ unzip \ jq \ - python2 \ python3 \ python3-requests \ python3-pip \ wget \ && rm -rf /var/lib/apt/lists/* -RUN curl -fsL https://github.com/warrensbox/terraform-switcher/releases/download/${TFSWITCH_VERSION}/terraform-switcher_${TFSWITCH_VERSION}_linux_amd64.tar.gz -o tfswitch.tar.gz \ - && tar -xvf tfswitch.tar.gz \ - && mv tfswitch /usr/local/bin \ - && rm -rf tfswitch \ - && tfswitch $DEFAULT_TF_VERSION RUN mkdir -p $TF_PLUGIN_CACHE_DIR COPY --from=tfmask /go/tfmask/release/tfmask /usr/local/bin/tfmask ENV TFMASK_RESOURCES_REGEX="(?i)^(random_id|kubernetes_secret|acme_certificate).*$" -ENTRYPOINT ["/usr/local/bin/terraform"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/terraform"] diff --git a/image/actions.sh b/image/actions.sh index dc360e9a..7cec9ac4 100644 --- a/image/actions.sh +++ b/image/actions.sh @@ -1,169 +1,427 @@ #!/bin/bash -set -eo pipefail +set -euo pipefail -function debug_log() { - echo "::debug::" "$@" -} - -function debug_cmd() { - local CMD_NAME - CMD_NAME=$(echo "$@") - "$@" | while IFS= read -r line; do echo "::debug::${CMD_NAME}:${line}"; done; -} +# shellcheck source=../workflow_commands.sh +source /usr/local/workflow_commands.sh function debug() { - debug_cmd pwd - debug_cmd ls -la - debug_cmd ls $HOME - debug_cmd printenv - debug_cmd cat "$GITHUB_EVENT_PATH" - echo + debug_cmd ls -la /root + debug_cmd pwd + debug_cmd ls -la + debug_cmd ls -la "$HOME" + debug_cmd printenv + debug_file "$GITHUB_EVENT_PATH" + echo } function detect-terraform-version() { - local TF_SWITCH_OUTPUT - - TF_SWITCH_OUTPUT=$(cd "$INPUT_PATH" && echo "" | tfswitch | grep -e Switched -e Reading | sed 's/^.*Switched/Switched/') - if echo "$TF_SWITCH_OUTPUT" | grep Reading >/dev/null; then - echo "$TF_SWITCH_OUTPUT" - else - echo "Setting terraform version" - if [[ "$INPUT_VERSION" == "" ]]; then - tfswitch "$(latest_terraform_version)" - else - tfswitch "$INPUT_VERSION" - fi - fi + debug_cmd ls -la "/usr/local/bin" + debug_cmd ls -la "$JOB_TMP_DIR/terraform-bin-dir" + TERRAFORM_BIN_DIR="/usr/local/bin:$JOB_TMP_DIR/terraform-bin-dir" terraform-version + debug_cmd ls -la "$(which terraform)" + + local TF_VERSION + TF_VERSION=$(terraform version -json | jq -r '.terraform_version' 2>/dev/null || terraform version | grep 'Terraform v' | sed 's/Terraform v//') + + TERRAFORM_VER_MAJOR=$(echo "$TF_VERSION" | cut -d. -f1) + TERRAFORM_VER_MINOR=$(echo "$TF_VERSION" | cut -d. -f2) + TERRAFORM_VER_PATCH=$(echo "$TF_VERSION" | cut -d. -f3) + + debug_log "Terraform version major $TERRAFORM_VER_MAJOR minor $TERRAFORM_VER_MINOR patch $TERRAFORM_VER_PATCH" +} + +function test-terraform-version() { + local OP="$1" + local VER="$2" + + python3 -c "exit(0 if ($TERRAFORM_VER_MAJOR, $TERRAFORM_VER_MINOR, $TERRAFORM_VER_PATCH) $OP tuple(int(v) for v in '$VER'.split('.')) else 1)" } function job_markdown_ref() { - echo "[${GITHUB_WORKFLOW} #${GITHUB_RUN_NUMBER}](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + echo "[${GITHUB_WORKFLOW} #${GITHUB_RUN_NUMBER}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" } function detect-tfmask() { - TFMASK="tfmask" - if ! hash tfmask 2>/dev/null; then - TFMASK="cat" - fi + TFMASK="tfmask" + if ! hash tfmask 2>/dev/null; then + TFMASK="cat" + fi - export TFMASK + export TFMASK +} + +function execute_run_commands() { + if [[ -v TERRAFORM_PRE_RUN ]]; then + start_group "Executing TERRAFORM_PRE_RUN" + + echo "Executing init commands specified in 'TERRAFORM_PRE_RUN' environment variable" + printf "%s" "$TERRAFORM_PRE_RUN" >"$STEP_TMP_DIR/TERRAFORM_PRE_RUN.sh" + disable_workflow_commands + bash -xeo pipefail "$STEP_TMP_DIR/TERRAFORM_PRE_RUN.sh" + enable_workflow_commands + + end_group + fi } function setup() { - export TF_DATA_DIR="$HOME/.dflook-terraform-data-dir" - export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache" - unset TF_WORKSPACE + if [[ "$INPUT_PATH" == "" ]]; then + error_log "input 'path' not set" + exit 1 + fi + + if [[ ! -d "$INPUT_PATH" ]]; then + error_log "Path does not exist: \"$INPUT_PATH\"" + exit 1 + fi + + if ! github_comment_react +1 2>"$STEP_TMP_DIR/github_comment_react.stderr"; then + debug_file "$STEP_TMP_DIR/github_comment_react.stderr" + fi + + export TF_DATA_DIR="$STEP_TMP_DIR/terraform-data-dir" + export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache" + mkdir -p "$TF_DATA_DIR" "$TF_PLUGIN_CACHE_DIR" "$JOB_TMP_DIR/terraform-bin-dir" + + unset TF_WORKSPACE + + write_credentials - mkdir -p "$TF_DATA_DIR" "$TF_PLUGIN_CACHE_DIR" + start_group "Installing Terraform" - if [[ "$INPUT_PATH" == "" ]]; then - echo "::error:: input 'path' not set" - exit 1 - fi + detect-terraform-version - if [[ ! -d "$INPUT_PATH" ]]; then - echo "::error:: Path does not exist: \"$INPUT_PATH\"" - exit 1 - fi + readonly TERRAFORM_BACKEND_TYPE=$(terraform-backend) + if [[ "$TERRAFORM_BACKEND_TYPE" != "" ]]; then + echo "Detected $TERRAFORM_BACKEND_TYPE backend" + fi + export TERRAFORM_BACKEND_TYPE + + end_group - detect-terraform-version - detect-tfmask + detect-tfmask + + execute_run_commands } function relative_to() { - local abspath - local relpath + local absbase + local relpath - absbase="$1" - relpath="$2" - realpath --no-symlinks --canonicalize-missing --relative-to="$absbase" "$relpath" + absbase="$1" + relpath="$2" + realpath --no-symlinks --canonicalize-missing --relative-to="$absbase" "$relpath" } +## +# Initialize terraform without a backend +# +# This only validates and installs plugins function init() { - rm -rf "$TF_DATA_DIR" - (cd "$INPUT_PATH" && terraform init -input=false -backend=false) + start_group "Initializing Terraform" + + rm -rf "$TF_DATA_DIR" + debug_log terraform init -input=false -backend=false + (cd "$INPUT_PATH" && terraform init -input=false -backend=false) + + end_group } -function init-backend() { - INIT_ARGS="" +function set-init-args() { + INIT_ARGS="" - if [[ -n "$INPUT_BACKEND_CONFIG_FILE" ]]; then - for file in $(echo "$INPUT_BACKEND_CONFIG_FILE" | tr ',' '\n'); do - INIT_ARGS="$INIT_ARGS -backend-config=$(relative_to "$INPUT_PATH" "$file")" - done - fi + if [[ -n "$INPUT_BACKEND_CONFIG_FILE" ]]; then + for file in $(echo "$INPUT_BACKEND_CONFIG_FILE" | tr ',' '\n'); do + INIT_ARGS="$INIT_ARGS -backend-config=$(relative_to "$INPUT_PATH" "$file")" + done + fi - if [[ -n "$INPUT_BACKEND_CONFIG" ]]; then - for config in $(echo "$INPUT_BACKEND_CONFIG" | tr ',' '\n'); do - INIT_ARGS="$INIT_ARGS -backend-config=$config" - done - fi + if [[ -n "$INPUT_BACKEND_CONFIG" ]]; then + for config in $(echo "$INPUT_BACKEND_CONFIG" | tr ',' '\n'); do + INIT_ARGS="$INIT_ARGS -backend-config=$config" + done + fi - export INIT_ARGS + export INIT_ARGS +} + +## +# Initialize the backend for a specific workspace +# +# The workspace must already exist, or the job will be failed +function init-backend-workspace() { + start_group "Initializing Terraform" + + set-init-args - rm -rf "$TF_DATA_DIR" + rm -rf "$TF_DATA_DIR" - set +e - (cd "$INPUT_PATH" && TF_WORKSPACE=$INPUT_WORKSPACE terraform init -input=false -lock-timeout=300s $INIT_ARGS \ - 2>"$PLAN_DIR/init_error.txt") + debug_log TF_WORKSPACE=$INPUT_WORKSPACE terraform init -input=false '$INIT_ARGS' # don't expand INIT_ARGS - local INIT_EXIT=$? - set -e + set +e + # shellcheck disable=SC2086 + (cd "$INPUT_PATH" && TF_WORKSPACE=$INPUT_WORKSPACE terraform init -input=false $INIT_ARGS \ + 2>"$STEP_TMP_DIR/terraform_init.stderr") - if [[ $INIT_EXIT -eq 0 ]]; then - cat "$PLAN_DIR/init_error.txt" >&2 - else - if grep -q "No existing workspaces." "$PLAN_DIR/init_error.txt" || grep -q "Failed to select workspace" "$PLAN_DIR/init_error.txt"; then - # Couldn't select workspace, but we don't really care. - # select-workspace will give a better error if the workspace is required to exist - : + local INIT_EXIT=$? + set -e + + if [[ $INIT_EXIT -eq 0 ]]; then + cat "$STEP_TMP_DIR/terraform_init.stderr" >&2 else - cat "$PLAN_DIR/init_error.txt" >&2 - exit $INIT_EXIT + if grep -q "No existing workspaces." "$STEP_TMP_DIR/terraform_init.stderr" || grep -q "Failed to select workspace" "$STEP_TMP_DIR/terraform_init.stderr" || grep -q "Currently selected workspace.*does not exist" "$STEP_TMP_DIR/terraform_init.stderr"; then + # Couldn't select workspace, but we don't really care. + # select-workspace will give a better error if the workspace is required to exist + cat "$STEP_TMP_DIR/terraform_init.stderr" + else + cat "$STEP_TMP_DIR/terraform_init.stderr" >&2 + exit $INIT_EXIT + fi fi - fi + + end_group + + select-workspace +} + +## +# Initialize terraform to use the default workspace +# +# This can be used to initialize when you don't know if a given workspace exists +# This can NOT be used with remote backend, as they have no default workspace +function init-backend-default-workspace() { + start_group "Initializing Terraform" + + set-init-args + + rm -rf "$TF_DATA_DIR" + + debug_log terraform init -input=false '$INIT_ARGS' # don't expand INIT_ARGS + set +e + # shellcheck disable=SC2086 + (cd "$INPUT_PATH" && terraform init -input=false $INIT_ARGS \ + 2>"$STEP_TMP_DIR/terraform_init.stderr") + + local INIT_EXIT=$? + set -e + + if [[ $INIT_EXIT -eq 0 ]]; then + cat "$STEP_TMP_DIR/terraform_init.stderr" >&2 + else + if grep -q "No existing workspaces." "$STEP_TMP_DIR/terraform_init.stderr" || grep -q "Failed to select workspace" "$STEP_TMP_DIR/terraform_init.stderr" || grep -q "Currently selected workspace.*does not exist" "$STEP_TMP_DIR/terraform_init.stderr"; then + # Couldn't select workspace, but we don't really care. + # select-workspace will give a better error if the workspace is required to exist + cat "$STEP_TMP_DIR/terraform_init.stderr" + else + cat "$STEP_TMP_DIR/terraform_init.stderr" >&2 + exit $INIT_EXIT + fi + fi + + end_group } function select-workspace() { - (cd "$INPUT_PATH" && terraform workspace select "$INPUT_WORKSPACE") + local WORKSPACE_EXIT + + debug_log terraform workspace select "$INPUT_WORKSPACE" + set +e + (cd "$INPUT_PATH" && terraform workspace select "$INPUT_WORKSPACE") >"$STEP_TMP_DIR/workspace_select" 2>&1 + WORKSPACE_EXIT=$? + set -e + + if [[ -s "$STEP_TMP_DIR/workspace_select" ]]; then + start_group "Selecting workspace" + + if [[ $WORKSPACE_EXIT -ne 0 ]] && grep -q "workspaces not supported" "$STEP_TMP_DIR/workspace_select" && [[ $INPUT_WORKSPACE == "default" ]]; then + echo "The full name of a remote workspace is set by the terraform configuration, selecting a different one is not supported" + WORKSPACE_EXIT=0 + else + cat "$STEP_TMP_DIR/workspace_select" + fi + + end_group + fi + + if [[ $WORKSPACE_EXIT -ne 0 ]]; then + exit $WORKSPACE_EXIT + fi +} + +function set-common-plan-args() { + PLAN_ARGS="" + PARALLEL_ARG="" + + if [[ "$INPUT_PARALLELISM" -ne 0 ]]; then + PARALLEL_ARG="-parallelism=$INPUT_PARALLELISM" + fi + + if [[ -v INPUT_TARGET ]]; then + if [[ -n "$INPUT_TARGET" ]]; then + for target in $(echo "$INPUT_TARGET" | tr ',' '\n'); do + PLAN_ARGS="$PLAN_ARGS -target $target" + done + fi + fi + + if [[ -v INPUT_REPLACE ]]; then + if [[ -n "$INPUT_REPLACE" ]]; then + for target in $(echo "$INPUT_REPLACE" | tr ',' '\n'); do + PLAN_ARGS="$PLAN_ARGS -replace $target" + done + fi + fi } function set-plan-args() { - PLAN_ARGS="" + set-common-plan-args - if [[ "$INPUT_PARALLELISM" -ne 0 ]]; then - PLAN_ARGS="$PLAN_ARGS -parallelism=$INPUT_PARALLELISM" - fi + if [[ -n "$INPUT_VAR" ]]; then + for var in $(echo "$INPUT_VAR" | tr ',' '\n'); do + PLAN_ARGS="$PLAN_ARGS -var $var" + done + fi + + if [[ -n "$INPUT_VAR_FILE" ]]; then + for file in $(echo "$INPUT_VAR_FILE" | tr ',' '\n'); do + PLAN_ARGS="$PLAN_ARGS -var-file=$(relative_to "$INPUT_PATH" "$file")" + done + fi + + if [[ -n "$INPUT_VARIABLES" ]]; then + echo "$INPUT_VARIABLES" >"$STEP_TMP_DIR/variables.tfvars" + PLAN_ARGS="$PLAN_ARGS -var-file=$STEP_TMP_DIR/variables.tfvars" + fi + + export PLAN_ARGS +} + +function set-remote-plan-args() { + set-common-plan-args + + local AUTO_TFVARS_COUNTER=0 - if [[ -n "$INPUT_VAR" ]]; then - for var in $(echo "$INPUT_VAR" | tr ',' '\n'); do - PLAN_ARGS="$PLAN_ARGS -var $var" - done - fi + if [[ -n "$INPUT_VAR_FILE" ]]; then + for file in $(echo "$INPUT_VAR_FILE" | tr ',' '\n'); do + cp "$file" "$INPUT_PATH/zzzz-dflook-terraform-github-actions-$AUTO_TFVARS_COUNTER.auto.tfvars" + AUTO_TFVARS_COUNTER=$((AUTO_TFVARS_COUNTER + 1)) + done + fi + + if [[ -n "$INPUT_VARIABLES" ]]; then + echo "$INPUT_VARIABLES" >"$STEP_TMP_DIR/variables.tfvars" + cp "$STEP_TMP_DIR/variables.tfvars" "$INPUT_PATH/zzzz-dflook-terraform-github-actions-$AUTO_TFVARS_COUNTER.auto.tfvars" + fi - if [[ -n "$INPUT_VAR_FILE" ]]; then - for file in $(echo "$INPUT_VAR_FILE" | tr ',' '\n'); do - PLAN_ARGS="$PLAN_ARGS -var-file=$(relative_to "$INPUT_PATH" "$file")" - done - fi + debug_cmd ls -la "$INPUT_PATH" - export PLAN_ARGS + export PLAN_ARGS } function output() { - (cd "$INPUT_PATH" && terraform output -json | convert_output) + debug_log terraform output -json + (cd "$INPUT_PATH" && terraform output -json | convert_output) } function update_status() { local status="$1" - if ! STATUS="$status" github_pr_comment status 2>&1 | sed 's/^/::debug::/'; then - echo "$status" - echo "Unable to update status on PR" + if ! STATUS="$status" github_pr_comment status 2>"$STEP_TMP_DIR/github_pr_comment.stderr"; then + debug_file "$STEP_TMP_DIR/github_pr_comment.stderr" + else + debug_file "$STEP_TMP_DIR/github_pr_comment.stderr" fi } function random_string() { - python3 -c "import random; import string; print(''.join(random.choice(string.ascii_lowercase) for i in range(8)))" + python3 -c "import random; import string; print(''.join(random.choice(string.ascii_lowercase) for i in range(8)))" } + +function write_credentials() { + format_tf_credentials >>"$HOME/.terraformrc" + chown --reference "$HOME" "$HOME/.terraformrc" + netrc-credential-actions >>"$HOME/.netrc" + chown --reference "$HOME" "$HOME/.netrc" + + chmod 700 /.ssh + if [[ -v TERRAFORM_SSH_KEY ]]; then + echo "$TERRAFORM_SSH_KEY" >>/.ssh/id_rsa + chmod 600 /.ssh/id_rsa + fi + + debug_cmd git config --list +} + +function plan() { + + local PLAN_OUT_ARG + if [[ -n "$PLAN_OUT" ]]; then + PLAN_OUT_ARG="-out=$PLAN_OUT" + else + PLAN_OUT_ARG="" + fi + + # shellcheck disable=SC2086 + debug_log terraform plan -input=false -no-color -detailed-exitcode -lock-timeout=300s $PARALLEL_ARG $PLAN_OUT_ARG '$PLAN_ARGS' # don't expand PLAN_ARGS + + set +e + # shellcheck disable=SC2086 + (cd "$INPUT_PATH" && terraform plan -input=false -no-color -detailed-exitcode -lock-timeout=300s $PARALLEL_ARG $PLAN_OUT_ARG $PLAN_ARGS) \ + 2>"$STEP_TMP_DIR/terraform_plan.stderr" \ + | $TFMASK \ + | tee /dev/fd/3 "$STEP_TMP_DIR/terraform_plan.stdout" \ + | compact_plan \ + >"$STEP_TMP_DIR/plan.txt" + + # shellcheck disable=SC2034 + PLAN_EXIT=${PIPESTATUS[0]} + set -e +} + +function destroy() { + # shellcheck disable=SC2086 + debug_log terraform destroy -input=false -auto-approve -lock-timeout=300s $PARALLEL_ARG $PLAN_ARGS + + set +e + # shellcheck disable=SC2086 + (cd "$INPUT_PATH" && terraform destroy -input=false -auto-approve -lock-timeout=300s $PARALLEL_ARG $PLAN_ARGS) \ + 2>"$STEP_TMP_DIR/terraform_destroy.stderr" \ + | tee /dev/fd/3 \ + >"$STEP_TMP_DIR/terraform_destroy.stdout" + + # shellcheck disable=SC2034 + DESTROY_EXIT=${PIPESTATUS[0]} + set -e +} + +# Every file written to disk should use one of these directories +STEP_TMP_DIR="/tmp" +JOB_TMP_DIR="$HOME/.dflook-terraform-github-actions" +WORKSPACE_TMP_DIR=".dflook-terraform-github-actions/$(random_string)" +readonly STEP_TMP_DIR JOB_TMP_DIR WORKSPACE_TMP_DIR +export STEP_TMP_DIR JOB_TMP_DIR WORKSPACE_TMP_DIR + +function fix_owners() { + debug_cmd ls -la "$GITHUB_WORKSPACE" + if [[ -d "$GITHUB_WORKSPACE/.dflook-terraform-github-actions" ]]; then + chown -R --reference "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE/.dflook-terraform-github-actions" || true + debug_cmd ls -la "$GITHUB_WORKSPACE/.dflook-terraform-github-actions" + fi + + debug_cmd ls -la "$HOME" + if [[ -d "$HOME/.dflook-terraform-github-actions" ]]; then + chown -R --reference "$HOME" "$HOME/.dflook-terraform-github-actions" || true + debug_cmd ls -la "$HOME/.dflook-terraform-github-actions" + fi + if [[ -d "$HOME/.terraform.d" ]]; then + chown -R --reference "$HOME" "$HOME/.terraform.d" || true + debug_cmd ls -la "$HOME/.terraform.d" + fi + + if [[ -d "$INPUT_PATH" ]]; then + debug_cmd find "$INPUT_PATH" -regex '.*/zzzz-dflook-terraform-github-actions-[0-9]+\.auto\.tfvars' -print -delete || true + fi +} + +trap fix_owners EXIT diff --git a/image/entrypoints/apply.sh b/image/entrypoints/apply.sh index 5a309719..ac0d43da 100755 --- a/image/entrypoints/apply.sh +++ b/image/entrypoints/apply.sh @@ -1,61 +1,56 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug - setup -init-backend -select-workspace +init-backend-workspace set-plan-args -PLAN_DIR=$HOME/$GITHUB_RUN_ID-$(random_string) -rm -rf "$PLAN_DIR" -mkdir -p "$PLAN_DIR" -PLAN_OUT="$PLAN_DIR/plan.out" - -if [[ "$INPUT_AUTO_APPROVE" == "true" && -n "$INPUT_TARGET" ]]; then - for target in $(echo "$INPUT_TARGET" | tr ',' '\n'); do - PLAN_ARGS="$PLAN_ARGS -target $target" - done -fi +PLAN_OUT="$STEP_TMP_DIR/plan.out" -if [[ -n "$GITHUB_TOKEN" ]]; then - update_status "Applying plan in $(job_markdown_ref)" +if [[ -v GITHUB_TOKEN ]]; then + update_status ":orange_circle: Applying plan in $(job_markdown_ref)" fi exec 3>&1 -function plan() { - - local PLAN_OUT_ARG - if [[ -n "$PLAN_OUT" ]]; then - PLAN_OUT_ARG=-out="$PLAN_OUT" - fi - - set +e - (cd $INPUT_PATH && terraform plan -input=false -no-color -detailed-exitcode -lock-timeout=300s $PLAN_OUT_ARG $PLAN_ARGS) \ - 2>"$PLAN_DIR/error.txt" \ - | $TFMASK \ - | tee /dev/fd/3 \ - | compact_plan \ - >"$PLAN_DIR/plan.txt" - - PLAN_EXIT=${PIPESTATUS[0]} - set -e -} - function apply() { + local APPLY_EXIT set +e - (cd $INPUT_PATH && terraform apply -input=false -no-color -auto-approve -lock-timeout=300s $PLAN_OUT) | $TFMASK - local APPLY_EXIT=${PIPESTATUS[0]} + if [[ -n "$PLAN_OUT" ]]; then + # shellcheck disable=SC2086 + debug_log terraform apply -input=false -no-color -auto-approve -lock-timeout=300s $PARALLEL_ARG $PLAN_OUT + # shellcheck disable=SC2086 + (cd "$INPUT_PATH" && terraform apply -input=false -no-color -auto-approve -lock-timeout=300s $PARALLEL_ARG $PLAN_OUT) | $TFMASK + APPLY_EXIT=${PIPESTATUS[0]} + else + # There is no plan file to apply, since the remote backend can't produce them. + # Instead we need to do an auto approved apply using the arguments we would normally use for the plan + + # shellcheck disable=SC2086 + debug_log terraform apply -input=false -no-color -auto-approve -lock-timeout=300s $PARALLEL_ARG '$PLAN_ARGS' # don't expand plan args + # shellcheck disable=SC2086 + (cd "$INPUT_PATH" && terraform apply -input=false -no-color -auto-approve -lock-timeout=300s $PARALLEL_ARG $PLAN_ARGS) | $TFMASK | tee "$STEP_TMP_DIR/terraform_apply.stdout" + APPLY_EXIT=${PIPESTATUS[0]} + + if remote-run-id "$STEP_TMP_DIR/terraform_apply.stdout" >"$STEP_TMP_DIR/remote-run-id.stdout" 2>"$STEP_TMP_DIR/remote-run-id.stderr"; then + RUN_ID="$(<"$STEP_TMP_DIR/remote-run-id.stdout")" + set_output run_id "$RUN_ID" + else + debug_log "Failed to get remote run-id" + debug_file "$STEP_TMP_DIR/remote-run-id.stderr" + fi + fi set -e if [[ $APPLY_EXIT -eq 0 ]]; then - update_status "Plan applied in $(job_markdown_ref)" + update_status ":white_check_mark: Plan applied in $(job_markdown_ref)" else - update_status "Error applying plan in $(job_markdown_ref)" + set_output failure-reason apply-failed + update_status ":x: Error applying plan in $(job_markdown_ref)" exit 1 fi } @@ -65,24 +60,44 @@ function apply() { plan if [[ $PLAN_EXIT -eq 1 ]]; then - if grep -q "Saving a generated plan is currently not supported" "$PLAN_DIR/error.txt"; then + if grep -q "Saving a generated plan is currently not supported" "$STEP_TMP_DIR/terraform_plan.stderr"; then + set-remote-plan-args PLAN_OUT="" if [[ "$INPUT_AUTO_APPROVE" == "true" ]]; then - # The apply will have to generate the plan, so skip doing it now - PLAN_EXIT=2 + # The apply will have to generate the plan, so skip doing it now + PLAN_EXIT=2 else - plan + plan fi fi fi if [[ $PLAN_EXIT -eq 1 ]]; then - cat "$PLAN_DIR/error.txt" - update_status "Error applying plan in $(job_markdown_ref)" + cat >&2 "$STEP_TMP_DIR/terraform_plan.stderr" + + update_status ":x: Error applying plan in $(job_markdown_ref)" exit 1 fi +if [[ -z "$PLAN_OUT" && "$INPUT_AUTO_APPROVE" == "true" ]]; then + # Since we are doing an auto approved remote apply there is no point in planning beforehand + # No text_plan_path output for this run + : +else + mkdir -p "$GITHUB_WORKSPACE/$WORKSPACE_TMP_DIR" + cp "$STEP_TMP_DIR/plan.txt" "$GITHUB_WORKSPACE/$WORKSPACE_TMP_DIR/plan.txt" + set_output text_plan_path "$WORKSPACE_TMP_DIR/plan.txt" +fi + +if [[ -n "$PLAN_OUT" ]]; then + if (cd "$INPUT_PATH" && terraform show -json "$PLAN_OUT") >"$GITHUB_WORKSPACE/$WORKSPACE_TMP_DIR/plan.json" 2>"$STEP_TMP_DIR/terraform_show.stderr"; then + set_output json_plan_path "$WORKSPACE_TMP_DIR/plan.json" + else + debug_file "$STEP_TMP_DIR/terraform_show.stderr" + fi +fi + ### Apply the plan if [[ "$INPUT_AUTO_APPROVE" == "true" || $PLAN_EXIT -eq 0 ]]; then @@ -91,28 +106,42 @@ if [[ "$INPUT_AUTO_APPROVE" == "true" || $PLAN_EXIT -eq 0 ]]; then else - if [[ "$GITHUB_EVENT_NAME" != "push" && "$GITHUB_EVENT_NAME" != "pull_request" && "$GITHUB_EVENT_NAME" != "issue_comment" && "$GITHUB_EVENT_NAME" != "pull_request_review_comment" ]]; then + if [[ "$GITHUB_EVENT_NAME" != "push" && "$GITHUB_EVENT_NAME" != "pull_request" && "$GITHUB_EVENT_NAME" != "issue_comment" && "$GITHUB_EVENT_NAME" != "pull_request_review_comment" && "$GITHUB_EVENT_NAME" != "pull_request_target" && "$GITHUB_EVENT_NAME" != "pull_request_review" ]]; then echo "Could not fetch plan from the PR - $GITHUB_EVENT_NAME event does not relate to a pull request. You can generate and apply a plan automatically by setting the auto_approve input to 'true'" exit 1 fi - if [[ -z "$GITHUB_TOKEN" ]]; then - echo "GITHUB_TOKEN environment variable must be set to get plan approval from a PR" - echo "Either set the GITHUB_TOKEN environment variable or automatically approve by setting the auto_approve input to 'true'" - echo "See https://github.com/dflook/terraform-github-actions/ for details." - exit 1 + if [[ ! -v GITHUB_TOKEN ]]; then + echo "GITHUB_TOKEN environment variable must be set to get plan approval from a PR" + echo "Either set the GITHUB_TOKEN environment variable or automatically approve by setting the auto_approve input to 'true'" + echo "See https://github.com/dflook/terraform-github-actions/ for details." + exit 1 fi - if ! github_pr_comment get >"$PLAN_DIR/approved-plan.txt"; then - echo "Approved plan not found" + if ! github_pr_comment get "$STEP_TMP_DIR/approved-plan.txt"; then + echo "Plan not found on PR" + echo "Generate the plan first using the dflook/terraform-plan action. Alternatively set the auto_approve input to 'true'" + echo "If dflook/terraform-plan was used with add_github_comment set to changes-only, this may mean the plan has since changed to include changes" + + set_output failure-reason plan-changed exit 1 fi - if plan_cmp "$PLAN_DIR/plan.txt" "$PLAN_DIR/approved-plan.txt"; then + if plan_cmp "$STEP_TMP_DIR/plan.txt" "$STEP_TMP_DIR/approved-plan.txt"; then apply else - debug_log diff "$PLAN_DIR/plan.txt" "$PLAN_DIR/approved-plan.txt" - update_status "Plan not applied in $(job_markdown_ref) (Plan has changed)" + echo "Not applying the plan - it has changed from the plan on the PR" + echo "The plan on the PR must be up to date. Alternatively, set the auto_approve input to 'true' to apply outdated plans" + update_status ":x: Plan not applied in $(job_markdown_ref) (Plan has changed)" + + echo "Performing diff between the pull request plan and the plan generated at execution time ..." + echo "> are lines from the plan in the pull request" + echo "< are lines from the plan generated at execution" + echo "Plan changes:" + debug_log diff "$STEP_TMP_DIR/plan.txt" "$STEP_TMP_DIR/approved-plan.txt" + diff "$STEP_TMP_DIR/plan.txt" "$STEP_TMP_DIR/approved-plan.txt" || true + + set_output failure-reason plan-changed exit 1 fi fi diff --git a/image/entrypoints/check.sh b/image/entrypoints/check.sh index b6274929..91bf1d4b 100755 --- a/image/entrypoints/check.sh +++ b/image/entrypoints/check.sh @@ -1,28 +1,38 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug setup -init-backend -select-workspace +init-backend-workspace set-plan-args -output -set +e -(cd $INPUT_PATH && terraform plan -input=false -detailed-exitcode -lock-timeout=300s $PLAN_ARGS) \ - | $TFMASK +exec 3>&1 -readonly TF_EXIT=${PIPESTATUS[0]} -set -e +PLAN_OUT="$STEP_TMP_DIR/plan.out" +PLAN_ARGS="$PLAN_ARGS -lock=false" +plan -if [[ $TF_EXIT -eq 1 ]]; then +if [[ $PLAN_EXIT -eq 1 ]]; then + if grep -q "Saving a generated plan is currently not supported" "$STEP_TMP_DIR/terraform_plan.stderr"; then + # This terraform module is using the remote backend, which is deficient. + set-remote-plan-args + PLAN_OUT="" + PLAN_ARGS="$PLAN_ARGS -lock=false" + plan + fi +fi + +if [[ $PLAN_EXIT -eq 1 ]]; then echo "Error running terraform" + cat >&2 "$STEP_TMP_DIR/terraform_plan.stderr" exit 1 -elif [[ $TF_EXIT -eq 2 ]]; then +elif [[ $PLAN_EXIT -eq 2 ]]; then echo "Changes detected!" + set_output failure-reason changes-to-apply exit 1 fi diff --git a/image/entrypoints/destroy-workspace.sh b/image/entrypoints/destroy-workspace.sh index c251daa7..1563c3bf 100755 --- a/image/entrypoints/destroy-workspace.sh +++ b/image/entrypoints/destroy-workspace.sh @@ -1,20 +1,36 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug setup -init-backend -select-workspace +init-backend-workspace set-plan-args -(cd "$INPUT_PATH" \ - && terraform destroy -input=false -auto-approve -lock-timeout=300s $PLAN_ARGS) +exec 3>&1 -# We can't delete an active workspace, so re-initialize with a 'default' workspace (which may not exist) -workspace=$INPUT_WORKSPACE -INPUT_WORKSPACE=default -init-backend +destroy -(cd "$INPUT_PATH" \ - && terraform workspace delete -no-color -lock-timeout=300s "$workspace") +if [[ $DESTROY_EXIT -eq 1 ]]; then + if grep -q "Run variables are currently not supported" "$STEP_TMP_DIR/terraform_destroy.stderr"; then + set-remote-plan-args + destroy + fi +fi + +if [[ $DESTROY_EXIT -eq 1 ]]; then + cat >&2 "$STEP_TMP_DIR/terraform_destroy.stderr" + set_output failure-reason destroy-failed + exit 1 +fi + +if [[ "$TERRAFORM_BACKEND_TYPE" == "remote" ]]; then + terraform-cloud-workspace delete "$INPUT_WORKSPACE" +else + # We can't delete an active workspace, so re-initialize with a 'default' workspace (which may not exist) + init-backend-default-workspace + + debug_log terraform workspace delete -no-color -lock-timeout=300s "$INPUT_WORKSPACE" + (cd "$INPUT_PATH" && terraform workspace delete -no-color -lock-timeout=300s "$INPUT_WORKSPACE") +fi diff --git a/image/entrypoints/destroy.sh b/image/entrypoints/destroy.sh index c8c2605e..134a3895 100755 --- a/image/entrypoints/destroy.sh +++ b/image/entrypoints/destroy.sh @@ -1,11 +1,26 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug setup -init-backend -select-workspace +init-backend-workspace set-plan-args -(cd "$INPUT_PATH" && terraform destroy -input=false -auto-approve -lock-timeout=300s $PLAN_ARGS) +exec 3>&1 + +destroy + +if [[ $DESTROY_EXIT -eq 1 ]]; then + if grep -q "Run variables are currently not supported" "$STEP_TMP_DIR/terraform_destroy.stderr"; then + set-remote-plan-args + destroy + fi +fi + +if [[ $DESTROY_EXIT -eq 1 ]]; then + cat >&2 "$STEP_TMP_DIR/terraform_destroy.stderr" + set_output failure-reason destroy-failed + exit 1 +fi diff --git a/image/entrypoints/fmt-check.sh b/image/entrypoints/fmt-check.sh index f4e3a800..e5ce1fdb 100755 --- a/image/entrypoints/fmt-check.sh +++ b/image/entrypoints/fmt-check.sh @@ -1,18 +1,24 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug setup -EXIT_CODE=0 -for file in $(terraform fmt -recursive -no-color -check "$INPUT_PATH"); do - echo "::error file=$file::File is not in canonical format (terraform fmt)" - EXIT_CODE=1 +terraform fmt -recursive -no-color -check -diff "$INPUT_PATH" | while IFS= read -r line; do + echo "$line" + + if [[ -f "$line" ]]; then + if [[ ! -v FAILURE_REASON_SET ]]; then + FAILURE_REASON_SET=yes + set_output failure-reason check-failed + fi + + echo "::error file=$line::File is not in canonical format (terraform fmt)" + fi done -if [[ "$EXIT_CODE" -eq 0 ]]; then - echo "All terraform configuration files are formatted correctly." -fi +# terraform fmt has non zero exit code if there are non canonical files -exit $EXIT_CODE +echo "All terraform configuration files are formatted correctly." diff --git a/image/entrypoints/fmt.sh b/image/entrypoints/fmt.sh index 8703805a..7566b7d7 100755 --- a/image/entrypoints/fmt.sh +++ b/image/entrypoints/fmt.sh @@ -1,5 +1,6 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug diff --git a/image/entrypoints/new-workspace.sh b/image/entrypoints/new-workspace.sh index 338376f6..8ab4982e 100755 --- a/image/entrypoints/new-workspace.sh +++ b/image/entrypoints/new-workspace.sh @@ -1,14 +1,66 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug setup -init-backend -if (cd "$INPUT_PATH" && terraform workspace list -no-color | workspace_exists "$INPUT_WORKSPACE"); then - echo "Workspace appears to exist, selecting it" - (cd "$INPUT_PATH" && terraform workspace select -no-color "$INPUT_WORKSPACE") +if [[ "$TERRAFORM_BACKEND_TYPE" == "remote" ]]; then + TERRAFORM_VERSION="$TERRAFORM_VER_MAJOR.$TERRAFORM_VER_MINOR.$TERRAFORM_VER_PATCH" terraform-cloud-workspace new "$INPUT_WORKSPACE" + exit 0 +fi + +init-backend-default-workspace + +set +e +(cd "$INPUT_PATH" && terraform workspace list -no-color) \ + 2>"$STEP_TMP_DIR/terraform_workspace_list.stderr" \ + >"$STEP_TMP_DIR/terraform_workspace_list.stdout" + +readonly TF_WS_LIST_EXIT=${PIPESTATUS[0]} +set -e + +debug_log "terraform workspace list: ${TF_WS_LIST_EXIT}" +debug_file "$STEP_TMP_DIR/terraform_workspace_list.stderr" +debug_file "$STEP_TMP_DIR/terraform_workspace_list.stdout" + +if [[ $TF_WS_LIST_EXIT -ne 0 ]]; then + echo "Error: Failed to list workspaces" + exit 1 +fi + +if workspace_exists "$INPUT_WORKSPACE" <"$STEP_TMP_DIR/terraform_workspace_list.stdout"; then + echo "Workspace appears to exist, selecting it" + (cd "$INPUT_PATH" && terraform workspace select -no-color "$INPUT_WORKSPACE") else - (cd "$INPUT_PATH" && terraform workspace new -no-color -lock-timeout=300s "$INPUT_WORKSPACE") + echo "Workspace does not appear to exist, attempting to create it" + + set +e + (cd "$INPUT_PATH" && terraform workspace new -no-color -lock-timeout=300s "$INPUT_WORKSPACE") \ + 2>"$STEP_TMP_DIR/terraform_workspace_new.stderr" \ + >"$STEP_TMP_DIR/terraform_workspace_new.stdout" + + readonly TF_WS_NEW_EXIT=${PIPESTATUS[0]} + set -e + + debug_log "terraform workspace new: ${TF_WS_NEW_EXIT}" + debug_file "$STEP_TMP_DIR/terraform_workspace_new.stderr" + debug_file "$STEP_TMP_DIR/terraform_workspace_new.stdout" + + if [[ $TF_WS_NEW_EXIT -ne 0 ]]; then + + if grep -Fq "already exists" "$STEP_TMP_DIR/terraform_workspace_new.stderr"; then + echo "Workspace does exist, selecting it" + (cd "$INPUT_PATH" && terraform workspace select -no-color "$INPUT_WORKSPACE") + else + cat "$STEP_TMP_DIR/terraform_workspace_new.stderr" + cat "$STEP_TMP_DIR/terraform_workspace_new.stdout" + exit 1 + fi + else + cat "$STEP_TMP_DIR/terraform_workspace_new.stderr" + cat "$STEP_TMP_DIR/terraform_workspace_new.stdout" + fi + fi diff --git a/image/entrypoints/output.sh b/image/entrypoints/output.sh index 148e927e..76502d44 100755 --- a/image/entrypoints/output.sh +++ b/image/entrypoints/output.sh @@ -1,8 +1,10 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh +debug setup -init-backend -select-workspace +init-backend-workspace + output diff --git a/image/entrypoints/plan.sh b/image/entrypoints/plan.sh index a9f42e3d..fa53c0d7 100755 --- a/image/entrypoints/plan.sh +++ b/image/entrypoints/plan.sh @@ -1,64 +1,109 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug setup -init-backend -select-workspace +init-backend-workspace set-plan-args -PLAN_DIR=$HOME/$GITHUB_RUN_ID-$(random_string) -rm -rf "$PLAN_DIR" -mkdir -p "$PLAN_DIR" - exec 3>&1 -set +e -(cd $INPUT_PATH && terraform plan -input=false -no-color -detailed-exitcode -lock-timeout=300s $PLAN_ARGS) \ - 2>"$PLAN_DIR/error.txt" \ - | $TFMASK \ - | tee /dev/fd/3 \ - | compact_plan \ - >"$PLAN_DIR/plan.txt" - -readonly TF_EXIT=${PIPESTATUS[0]} -set -e - -cat "$PLAN_DIR/error.txt" +### Generate a plan +PLAN_OUT="$STEP_TMP_DIR/plan.out" +PLAN_ARGS="$PLAN_ARGS -lock=false" +plan -if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "issue_comment" || "$GITHUB_EVENT_NAME" == "pull_request_review_comment" ]]; then - if [[ "$INPUT_ADD_GITHUB_COMMENT" == "true" ]]; then - - if [[ -z "$GITHUB_TOKEN" ]]; then - echo "GITHUB_TOKEN environment variable must be set to add GitHub PR comments" - echo "Either set the GITHUB_TOKEN environment variable, or disable by setting the add_github_comment input to 'false'" - echo "See https://github.com/dflook/terraform-github-actions/ for details." - exit 1 +if [[ $PLAN_EXIT -eq 1 ]]; then + if grep -q "Saving a generated plan is currently not supported" "$STEP_TMP_DIR/terraform_plan.stderr"; then + # This terraform module is using the remote backend, which is deficient. + set-remote-plan-args + PLAN_OUT="" + PLAN_ARGS="$PLAN_ARGS -lock=false" + plan fi +fi + +cat "$STEP_TMP_DIR/terraform_plan.stderr" - if [[ $TF_EXIT -eq 1 ]]; then - STATUS="Failed to generate plan in $(job_markdown_ref)" github_pr_comment plan <"/$PLAN_DIR/error.txt" +if [[ -z "$PLAN_OUT" ]]; then + if remote-run-id "$STEP_TMP_DIR/terraform_plan.stdout" >"$STEP_TMP_DIR/remote-run-id.stdout" 2>"$STEP_TMP_DIR/remote-run-id.stderr"; then + RUN_ID="$(<"$STEP_TMP_DIR/remote-run-id.stdout")" + set_output run_id "$RUN_ID" else - STATUS="Plan generated in $(job_markdown_ref)" github_pr_comment plan <"/$PLAN_DIR/plan.txt" + debug_log "Failed to get remote run-id" + debug_file "$STEP_TMP_DIR/remote-run-id.stderr" fi +fi + +if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "issue_comment" || "$GITHUB_EVENT_NAME" == "pull_request_review_comment" || "$GITHUB_EVENT_NAME" == "pull_request_target" || "$GITHUB_EVENT_NAME" == "pull_request_review" ]]; then + if [[ "$INPUT_ADD_GITHUB_COMMENT" == "true" || "$INPUT_ADD_GITHUB_COMMENT" == "changes-only" ]]; then + + if [[ ! -v GITHUB_TOKEN ]]; then + echo "GITHUB_TOKEN environment variable must be set to add GitHub PR comments" + echo "Either set the GITHUB_TOKEN environment variable, or disable by setting the add_github_comment input to 'false'" + echo "See https://github.com/dflook/terraform-github-actions/ for details." + exit 1 + fi + + if [[ $PLAN_EXIT -eq 1 ]]; then + if ! STATUS=":x: Failed to generate plan in $(job_markdown_ref)" github_pr_comment plan <"$STEP_TMP_DIR/terraform_plan.stderr"; then + exit 1 + fi + + else + + if [[ $PLAN_EXIT -eq 0 ]]; then + TF_CHANGES=false + else # [[ $PLAN_EXIT -eq 2 ]] + TF_CHANGES=true + fi - fi + if ! TF_CHANGES=$TF_CHANGES STATUS=":memo: Plan generated in $(job_markdown_ref)" github_pr_comment plan <"$STEP_TMP_DIR/plan.txt"; then + exit 1 + fi + fi + + fi else - debug_log "Not a pull_request, issue_comment or pull_request_review_comment event - not creating a PR comment" + debug_log "Not a pull_request, issue_comment, pull_request_target, pull_request_review or pull_request_review_comment event - not creating a PR comment" fi -if [[ $TF_EXIT -eq 1 ]]; then +if [[ $PLAN_EXIT -eq 1 ]]; then debug_log "Error running terraform" exit 1 -fi -if [[ $TF_EXIT -eq 0 ]]; then +elif [[ $PLAN_EXIT -eq 0 ]]; then debug_log "No Changes to apply" - echo "::set-output name=changes::false" + set_output changes false -elif [[ $TF_EXIT -eq 2 ]]; then +elif [[ $PLAN_EXIT -eq 2 ]]; then debug_log "Changes to apply" - echo "::set-output name=changes::true" + set_output changes true + + plan_summary "$STEP_TMP_DIR/plan.txt" +fi + +mkdir -p "$GITHUB_WORKSPACE/$WORKSPACE_TMP_DIR" +cp "$STEP_TMP_DIR/plan.txt" "$GITHUB_WORKSPACE/$WORKSPACE_TMP_DIR/plan.txt" +set_output text_plan_path "$WORKSPACE_TMP_DIR/plan.txt" + +if [[ -n "$PLAN_OUT" ]]; then + if (cd "$INPUT_PATH" && terraform show -json "$PLAN_OUT") >"$GITHUB_WORKSPACE/$WORKSPACE_TMP_DIR/plan.json" 2>"$STEP_TMP_DIR/terraform_show.stderr"; then + set_output json_plan_path "$WORKSPACE_TMP_DIR/plan.json" + else + debug_file "$STEP_TMP_DIR/terraform_show.stderr" + fi +elif [[ -n "$RUN_ID" ]]; then + if terraform-cloud-state "$RUN_ID" >"$STEP_TMP_DIR/terraform_cloud_state.stdout" 2>"$STEP_TMP_DIR/terraform_cloud_state.stderr"; then + debug_log "Fetched JSON plan from TFC" + cp "$STEP_TMP_DIR/terraform_cloud_state.stdout" "$GITHUB_WORKSPACE/$WORKSPACE_TMP_DIR/plan.json" + set_output json_plan_path "$WORKSPACE_TMP_DIR/plan.json" + else + debug_log "Failed to fetch JSON plan from TFC" + debug_file "$STEP_TMP_DIR/terraform_cloud_state.stdout" + debug_file "$STEP_TMP_DIR/terraform_cloud_state.stderr" + fi fi diff --git a/image/entrypoints/remote-state.sh b/image/entrypoints/remote-state.sh index 7cdb19e1..1637b2ca 100755 --- a/image/entrypoints/remote-state.sh +++ b/image/entrypoints/remote-state.sh @@ -1,20 +1,22 @@ #!/bin/bash +# shellcheck source=../actions.sh source /usr/local/actions.sh debug -INPUT_PATH="$HOME/.dflook-terraform-remote-state" +INPUT_PATH="$STEP_TMP_DIR/remote-state" +export INPUT_PATH + rm -rf "$INPUT_PATH" mkdir -p "$INPUT_PATH" -cat > "$INPUT_PATH/backend.tf" <"$INPUT_PATH/backend.tf" < requests.Response: + response = self._session.request(method, *args, **kwargs) + + if 400 <= response.status_code < 500: + debug(str(response.headers)) + + try: + message = response.json()['message'] + + if response.headers['X-RateLimit-Remaining'] == '0': + limit_reset = datetime.datetime.fromtimestamp(int(response.headers['X-RateLimit-Reset'])) + sys.stdout.write(message) + sys.stdout.write(f' Try again when the rate limit resets at {limit_reset} UTC.\n') + sys.exit(1) + + if message != 'Resource not accessible by integration': + sys.stdout.write(message) + sys.stdout.write('\n') + debug(response.content.decode()) + + except Exception: + sys.stdout.write(response.content.decode()) + sys.stdout.write('\n') + raise + + return response + + def get(self, path: str, **kwargs: Any) -> Response: + return self.api_request('GET', path, **kwargs) + + def post(self, path: str, **kwargs: Any) -> Response: + return self.api_request('POST', path, **kwargs) + + def patch(self, path: str, **kwargs: Any) -> Response: + return self.api_request('PATCH', path, **kwargs) + + def paged_get(self, url: GitHubUrl, *args, **kwargs) -> Iterable[dict[str, Any]]: + while True: + response = self.api_request('GET', url, *args, **kwargs) + response.raise_for_status() + + yield from response.json() + + if 'next' in response.links: + url = response.links['next']['url'] + else: + return diff --git a/image/src/github_actions/cache.py b/image/src/github_actions/cache.py new file mode 100644 index 00000000..c27b6290 --- /dev/null +++ b/image/src/github_actions/cache.py @@ -0,0 +1,34 @@ +import os +from pathlib import Path + +from github_actions.debug import debug + + +class ActionsCache: + + def __init__(self, cache_dir: Path, label: str=None): + self._cache_dir = cache_dir + self._label = label or self._cache_dir + + def __setitem__(self, key, value): + if value is None: + debug(f'Cache value for {key} should not be set to {value}') + return + + path = os.path.join(self._cache_dir, key) + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(os.path.join(self._cache_dir, key), 'w') as f: + f.write(value) + debug(f'Wrote {key} to {self._label}') + + def __getitem__(self, key): + if os.path.isfile(os.path.join(self._cache_dir, key)): + with open(os.path.join(self._cache_dir, key)) as f: + debug(f'Read {key} from {self._label}') + return f.read() + + raise IndexError(key) + + def __contains__(self, key): + return os.path.isfile(os.path.join(self._cache_dir, key)) diff --git a/image/src/github_actions/commands.py b/image/src/github_actions/commands.py new file mode 100644 index 00000000..bff567b6 --- /dev/null +++ b/image/src/github_actions/commands.py @@ -0,0 +1,6 @@ +import sys +from typing import Any + + +def output(name: str, value: Any) -> None: + sys.stdout.write(f'::set-output name={name}::{value}\n') diff --git a/image/src/github_actions/debug.py b/image/src/github_actions/debug.py new file mode 100644 index 00000000..cf7eedad --- /dev/null +++ b/image/src/github_actions/debug.py @@ -0,0 +1,10 @@ +"""Actions debug logging""" + +import sys + + +def debug(msg: str) -> None: + """Add a message to the actions debug log.""" + + for line in msg.splitlines(): + sys.stderr.write(f'::debug::{line}\n') diff --git a/image/src/github_actions/env.py b/image/src/github_actions/env.py new file mode 100644 index 00000000..f1ec3bc9 --- /dev/null +++ b/image/src/github_actions/env.py @@ -0,0 +1,27 @@ +"""GitHub action environment variables.""" + +from __future__ import annotations + +from typing import TypedDict + + +class ActionsEnv(TypedDict): + """Environment variables expected by these actions.""" + TERRAFORM_CLOUD_TOKENS: str + TERRAFORM_SSH_KEY: str + TERRAFORM_PRE_RUN: str + TERRAFORM_HTTP_CREDENTIALS: str + TERRAFORM_VERSION: str + + +class GithubEnv(TypedDict): + """Environment variables that are set by the actions runner.""" + GITHUB_API_URL: str + GITHUB_TOKEN: str + GITHUB_EVENT_PATH: str + GITHUB_EVENT_NAME: str + GITHUB_REPOSITORY: str + GITHUB_SHA: str + GITHUB_REF_TYPE: str + GITHUB_REF: str + GITHUB_WORKSPACE: str diff --git a/image/src/github_actions/find_pr.py b/image/src/github_actions/find_pr.py new file mode 100644 index 00000000..9970fcfd --- /dev/null +++ b/image/src/github_actions/find_pr.py @@ -0,0 +1,75 @@ +import json +import os +import re +from typing import Optional, Any, cast, Iterable + +from github_actions.api import PrUrl, GithubApi +from github_actions.debug import debug +from github_actions.env import GithubEnv + + +class WorkflowException(Exception): + """An exception that should result in an error in the workflow log""" + + +def find_pr(github: GithubApi, actions_env: GithubEnv) -> PrUrl: + """ + Find the pull request this event is related to + + >>> find_pr() + 'https://api.github.com/repos/dflook/terraform-github-actions/pulls/8' + + """ + + event: Optional[dict[str, Any]] + + if os.path.isfile(actions_env['GITHUB_EVENT_PATH']): + with open(actions_env['GITHUB_EVENT_PATH']) as f: + event = json.load(f) + else: + debug('Event payload is not available') + event = None + + event_type = actions_env['GITHUB_EVENT_NAME'] + + if event_type in ['pull_request', 'pull_request_review_comment', 'pull_request_target', 'pull_request_review', 'issue_comment']: + + if event is not None: + # Pull pr url from event payload + + if event_type in ['pull_request', 'pull_request_review_comment', 'pull_request_target', 'pull_request_review']: + return cast(PrUrl, event['pull_request']['url']) + + if event_type == 'issue_comment': + + if 'pull_request' in event['issue']: + return cast(PrUrl, event['issue']['pull_request']['url']) + else: + raise WorkflowException('This comment is not for a PR. Add a filter of `if: github.event.issue.pull_request`') + + else: + # Event payload is not available + + if actions_env.get('GITHUB_REF_TYPE') == 'branch': + if match := re.match(r'refs/pull/(\d+)/', actions_env.get('GITHUB_REF', '')): + return cast(PrUrl, f'{actions_env["GITHUB_API_URL"]}/repos/{actions_env["GITHUB_REPOSITORY"]}/pulls/{match.group(1)}') + + raise WorkflowException(f'Event payload is not available at the GITHUB_EVENT_PATH {actions_env["GITHUB_EVENT_PATH"]!r}. ' + + f'This is required when run by {event_type} events. The environment has not been setup properly by the actions runner. ' + + 'This can happen when the runner is running in a container') + + elif event_type == 'push': + repo = actions_env['GITHUB_REPOSITORY'] + commit = actions_env['GITHUB_SHA'] + + def prs() -> Iterable[dict[str, Any]]: + url = cast(PrUrl, f'{actions_env["GITHUB_API_URL"]}/repos/{repo}/pulls') + yield from github.paged_get(url, params={'state': 'all'}) + + for pr in prs(): + if pr['merge_commit_sha'] == commit: + return cast(PrUrl, pr['url']) + + raise WorkflowException(f'No PR found in {repo} for commit {commit} (was it pushed directly to the target branch?)') + + raise WorkflowException(f"The {event_type} event doesn\'t relate to a Pull Request.") diff --git a/image/src/github_actions/inputs.py b/image/src/github_actions/inputs.py new file mode 100644 index 00000000..231d4fbc --- /dev/null +++ b/image/src/github_actions/inputs.py @@ -0,0 +1,84 @@ +""" +Typed Action input classes +""" + +from __future__ import annotations + +from typing import TypedDict + + +class InitInputs(TypedDict): + """Common input variables for actions the need to initialize terraform""" + INPUT_PATH: str + INPUT_WORKSPACE: str + INPUT_BACKEND_CONFIG: str + INPUT_BACKEND_CONFIG_FILE: str + + +class PlanInputs(InitInputs): + """Common input variables for actions that generate a plan""" + INPUT_VARIABLES: str + INPUT_VAR: str + INPUT_VAR_FILE: str + INPUT_PARALLELISM: str + + +class PlanPrInputs(PlanInputs): + """Common input variables for actions that use a PR comment""" + INPUT_LABEL: str + INPUT_TARGET: str + INPUT_REPLACE: str + + +class Plan(PlanPrInputs): + """Input variables for the plan action""" + INPUT_ADD_GITHUB_COMMENT: str + + +class Apply(PlanPrInputs): + """Input variables for the terraform-apply action""" + INPUT_AUTO_APPROVE: str + + +class Check(PlanInputs): + """Input variables for the terraform-check action""" + + +class Destroy(PlanInputs): + """Input variables for the terraform-destroy action""" + + +class DestroyWorkspace(PlanInputs): + """Input variables for the terraform-destroy-workspace action""" + + +class Fmt(InitInputs): + """Input variables for the terraform-fmt action""" + + +class FmtCheck(InitInputs): + """Input variables for the terraform-fmt-check action""" + + +class Version(InitInputs): + """Input variables for the terraform-version action""" + + +class NewWorkspace(InitInputs): + """Input variables for the terraform-new-workspace action""" + + +class Output(InitInputs): + """Input variables for the terraform-output action""" + + +class RemoteState(TypedDict): + """Input variables for the terraform-remote-state action""" + INPUT_BACKEND_TYPE: str + INPUT_WORKSPACE: str + INPUT_BACKEND_CONFIG: str + INPUT_BACKEND_CONFIG_FILE: str + + +class Validate(InitInputs): + """Input variables for the terraform-validate action""" diff --git a/tests/version/tfenv/main.tf b/image/src/github_pr_comment/__init__.py similarity index 100% rename from tests/version/tfenv/main.tf rename to image/src/github_pr_comment/__init__.py diff --git a/image/src/github_pr_comment/__main__.py b/image/src/github_pr_comment/__main__.py new file mode 100644 index 00000000..73c54683 --- /dev/null +++ b/image/src/github_pr_comment/__main__.py @@ -0,0 +1,279 @@ +import hashlib +import json +import os +import sys +from pathlib import Path +from typing import (NewType, Optional, cast) + +import canonicaljson + +from github_actions.api import GithubApi, IssueUrl, PrUrl +from github_actions.cache import ActionsCache +from github_actions.debug import debug +from github_actions.env import GithubEnv +from github_actions.find_pr import find_pr, WorkflowException +from github_actions.inputs import PlanPrInputs +from github_pr_comment.backend_config import complete_config +from github_pr_comment.backend_fingerprint import fingerprint +from github_pr_comment.comment import find_comment, TerraformComment, update_comment, serialize, deserialize +from terraform.module import load_module + +Plan = NewType('Plan', str) +Status = NewType('Status', str) + +job_cache = ActionsCache(Path(os.environ.get('JOB_TMP_DIR', '.')), 'job_cache') +step_cache = ActionsCache(Path(os.environ.get('STEP_TMP_DIR', '.')), 'step_cache') + +env = cast(GithubEnv, os.environ) + +github = GithubApi(env.get('GITHUB_API_URL', 'https://api.github.com'), env['GITHUB_TOKEN']) + + +def _mask_backend_config(action_inputs: PlanPrInputs) -> Optional[str]: + bad_words = [ + 'token', + 'password', + 'sas_token', + 'access_key', + 'secret_key', + 'client_secret', + 'access_token', + 'http_auth', + 'secret_id', + 'encryption_key', + 'key_material', + 'security_token', + 'conn_str', + 'sse_customer_key', + 'application_credential_secret' + ] + + clean = [] + + for field in action_inputs.get('INPUT_BACKEND_CONFIG', '').split(','): + if not field: + continue + + if not any(bad_word in field for bad_word in bad_words): + clean.append(field) + + return ','.join(clean) + + +def format_classic_description(action_inputs: PlanPrInputs) -> str: + if action_inputs['INPUT_LABEL']: + return f'Terraform plan for __{action_inputs["INPUT_LABEL"]}__' + + label = f'Terraform plan in __{action_inputs["INPUT_PATH"]}__' + + if action_inputs["INPUT_WORKSPACE"] != 'default': + label += f' in the __{action_inputs["INPUT_WORKSPACE"]}__ workspace' + + if action_inputs["INPUT_TARGET"]: + label += '\nTargeting resources: ' + label += ', '.join(f'`{res.strip()}`' for res in action_inputs['INPUT_TARGET'].splitlines()) + + if action_inputs["INPUT_REPLACE"]: + label += '\nReplacing resources: ' + label += ', '.join(f'`{res.strip()}`' for res in action_inputs['INPUT_REPLACE'].splitlines()) + + if backend_config := _mask_backend_config(action_inputs): + label += f'\nWith backend config: `{backend_config}`' + + if action_inputs["INPUT_BACKEND_CONFIG_FILE"]: + label += f'\nWith backend config files: `{action_inputs["INPUT_BACKEND_CONFIG_FILE"]}`' + + if action_inputs["INPUT_VAR"]: + label += f'\nWith vars: `{action_inputs["INPUT_VAR"]}`' + + if action_inputs["INPUT_VAR_FILE"]: + label += f'\nWith var files: `{action_inputs["INPUT_VAR_FILE"]}`' + + if action_inputs["INPUT_VARIABLES"]: + stripped_vars = action_inputs["INPUT_VARIABLES"].strip() + if '\n' in stripped_vars: + label += f'''

With variables + +```hcl +{stripped_vars} +``` +
+''' + else: + label += f'\nWith variables: `{stripped_vars}`' + + return label + + +def create_summary(plan: Plan) -> Optional[str]: + summary = None + + for line in plan.splitlines(): + if line.startswith('No changes') or line.startswith('Error'): + return line + + if line.startswith('Plan:'): + summary = line + + if line.startswith('Changes to Outputs'): + if summary: + return summary + ' Changes to Outputs.' + else: + return 'Changes to Outputs' + + return summary + + +def current_user(actions_env: GithubEnv) -> str: + token_hash = hashlib.sha256(actions_env['GITHUB_TOKEN'].encode()).hexdigest() + cache_key = f'token-cache/{token_hash}' + + if cache_key in job_cache: + username = job_cache[cache_key] + else: + response = github.get(f'{actions_env["GITHUB_API_URL"]}/user') + if response.status_code != 403: + user = response.json() + debug(json.dumps(user)) + + username = user['login'] + else: + # Assume this is the github actions app token + username = 'github-actions[bot]' + + job_cache[cache_key] = username + + return username + + +def get_issue_url(pr_url: str) -> IssueUrl: + pr_hash = hashlib.sha256(pr_url.encode()).hexdigest() + cache_key = f'issue-href-cache/{pr_hash}' + + if cache_key in job_cache: + issue_url = job_cache[cache_key] + else: + response = github.get(pr_url) + response.raise_for_status() + issue_url = response.json()['_links']['issue']['href'] + '/comments' + + job_cache[cache_key] = issue_url + + return cast(IssueUrl, issue_url) + + +def get_pr() -> PrUrl: + if 'pr_url' in step_cache: + pr_url = step_cache['pr_url'] + else: + try: + pr_url = find_pr(github, env) + step_cache['pr_url'] = pr_url + except WorkflowException as e: + sys.stderr.write('\n' + str(e) + '\n') + sys.exit(1) + + return cast(PrUrl, pr_url) + +def comment_hash(value: bytes, salt: str) -> str: + h = hashlib.sha256(f'dflook/terraform-github-actions/{salt}'.encode()) + h.update(value) + return h.hexdigest() + +def get_comment(action_inputs: PlanPrInputs, backend_fingerprint: bytes) -> TerraformComment: + if 'comment' in step_cache: + return deserialize(step_cache['comment']) + + pr_url = get_pr() + issue_url = get_issue_url(pr_url) + username = current_user(env) + + legacy_description = format_classic_description(action_inputs) + + headers = { + 'workspace': os.environ.get('INPUT_WORKSPACE', 'default'), + 'backend': comment_hash(backend_fingerprint, pr_url) + } + + if backend_type := os.environ.get('TERRAFORM_BACKEND_TYPE'): + headers['backend_type'] = backend_type + + headers['label'] = os.environ.get('INPUT_LABEL') or None + + plan_modifier = {} + if target := os.environ.get('INPUT_TARGET'): + plan_modifier['target'] = sorted(t.strip() for t in target.replace(',', '\n', ).split('\n') if t.strip()) + + if replace := os.environ.get('INPUT_REPLACE'): + plan_modifier['replace'] = sorted(t.strip() for t in replace.replace(',', '\n', ).split('\n') if t.strip()) + + if plan_modifier: + debug(f'Plan modifier: {plan_modifier}') + headers['plan_modifier'] = hashlib.sha256(canonicaljson.encode_canonical_json(plan_modifier)).hexdigest() + + return find_comment(github, issue_url, username, headers, legacy_description) + +def main() -> int: + if len(sys.argv) < 2: + sys.stderr.write(f'''Usage: + STATUS="" {sys.argv[0]} plan Tuple[BackendType, BackendConfig]: + """Return the backend config specified in the terraform module.""" + + for terraform in module.get('terraform', []): + for backend in terraform.get('backend', []): + for backend_type, config in backend.items(): + return backend_type, config + + for cloud in terraform.get('cloud', []): + return 'cloud', cloud + + return 'local', {} + + +def read_backend_config_vars(init_inputs: InitInputs) -> BackendConfig: + """Read any backend config from input variables.""" + + config: BackendConfig = {} + + for path in init_inputs.get('INPUT_BACKEND_CONFIG_FILE', '').replace(',', '\n').splitlines(): + try: + config |= load_backend_config_file(Path(path)) # type: ignore + except Exception as e: + debug(f'Failed to load backend config file {path}') + debug(str(e)) + + for backend_var in init_inputs.get('INPUT_BACKEND_CONFIG', '').replace(',', '\n').splitlines(): + if match := re.match(r'(.*)\s*=\s*(.*)', backend_var): + config[match.group(1)] = match.group(2) + + return config + + +def complete_config(action_inputs: InitInputs, module: TerraformModule) -> Tuple[BackendType, BackendConfig]: + backend_type, config = partial_backend_config(module) + + for key, value in read_backend_config_vars(action_inputs).items(): + config[key] = value + + return backend_type, config diff --git a/image/src/github_pr_comment/backend_fingerprint.py b/image/src/github_pr_comment/backend_fingerprint.py new file mode 100644 index 00000000..23941276 --- /dev/null +++ b/image/src/github_pr_comment/backend_fingerprint.py @@ -0,0 +1,199 @@ +""" +Backend fingerprinting + +Given a completed backend config and environment variables, compute a fingerprint that identifies that backend config. +This disregards any config related to *how* that backend is used. + +Combined with the backend type and workspace name, this should uniquely identify a remote state file. + +""" +import canonicaljson + +from github_actions.debug import debug +from github_pr_comment.backend_config import BackendConfig, BackendType + + +def fingerprint_remote(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'hostname': backend_config.get('hostname', ''), + 'organization': backend_config.get('organization', ''), + 'workspaces': backend_config.get('workspaces', '') + } + + +def fingerprint_cloud(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'hostname': backend_config.get('hostname', ''), + 'organization': backend_config.get('organization', ''), + 'workspaces': backend_config.get('workspaces', '') + } + + +def fingerprint_artifactory(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'url': backend_config.get('url') or env.get('ARTIFACTORY_URL', ''), + 'repo': backend_config.get('repo', ''), + 'subpath': backend_config.get('subpath', '') + } + + +def fingerprint_azurerm(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'storage_account_name': backend_config.get('storage_account_name', ''), + 'container_name': backend_config.get('container_name', ''), + 'key': backend_config.get('key', ''), + 'environment': backend_config.get('environment') or env.get('ARM_ENVIRONMENT', ''), + 'endpoint': backend_config.get('endpoint') or env.get('ARM_ENDPOINT', ''), + 'resource_group_name': backend_config.get('resource_group_name', ''), + 'msi_endpoint': backend_config.get('msi_endpoint') or env.get('ARM_MSI_ENDPOINT', ''), + 'subscription_id': backend_config.get('subscription_id') or env.get('ARM_SUBSCRIPTION_ID', ''), + 'tenant_id': backend_config.get('tenant_id') or env.get('ARM_TENANT_ID', ''), + } + + +def fingerprint_consul(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'path': backend_config.get('path', ''), + 'address': backend_config.get('address') or env.get('CONSUL_HTTP_ADDR', ''), + } + + +def fingerprint_cos(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'bucket': backend_config.get('bucket', ''), + 'prefix': backend_config.get('prefix', ''), + 'key': backend_config.get('key', ''), + 'region': backend_config.get('region', '') + } + + +def fingerprint_etcd(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'path': backend_config.get('path', ''), + 'endpoints': ' '.join(sorted(backend_config.get('endpoints', '').split(' '))) + } + + +def fingerprint_etcd3(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'prefix': backend_config.get('prefix', ''), + 'endpoints': ' '.join(sorted(backend_config.get('endpoints', []))) + } + + +def fingerprint_gcs(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'bucket': backend_config.get('bucket', ''), + 'prefix': backend_config.get('prefix', '') + } + + +def fingerprint_http(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'address': backend_config.get('address') or env.get('TF_HTTP_ADDRESS', ''), + 'lock_address': backend_config.get('lock_address') or env.get('TF_HTTP_LOCK_ADDRESS', ''), + 'unlock_address': backend_config.get('unlock_address') or env.get('TF_HTTP_UNLOCK_ADDRESS', ''), + } + + +def fingerprint_kubernetes(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'secret_suffix': backend_config.get('secret_suffix', ''), + 'namespace': backend_config.get('namespace') or env.get('KUBE_NAMESPACE', ''), + 'host': backend_config.get('host') or env.get('KUBE_HOST', ''), + 'config_path': backend_config.get('config_path') or env.get('KUBE_CONFIG_PATH', ''), + 'config_paths': backend_config.get('config_paths') or env.get('KUBE_CONFIG_PATHS', ''), + 'config_context': backend_config.get('context') or env.get('KUBE_CTX', ''), + 'config_context_cluster': backend_config.get('config_context_cluster') or env.get('KUBE_CTX_CLUSTER', '') + } + + +def fingerprint_manta(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'account': backend_config.get('account') or env.get('SDC_ACCOUNT') or env.get('TRITON_ACCOUNT', ''), + 'url': backend_config.get('url') or env.get('MANTA_URL', ''), + 'path': backend_config.get('path', ''), + 'object_name': backend_config.get('object_name', '') + } + + +def fingerprint_oss(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'region': backend_config.get('region') or env.get('ALICLOUD_REGION') or env.get('ALICLOUD_DEFAULT_REGION', ''), + 'endpoint': backend_config.get('endpoint') or env.get('ALICLOUD_OSS_ENDPOINT') or env.get('OSS_ENDPOINT', ''), + 'bucket': backend_config.get('bucket', ''), + 'prefix': backend_config.get('prefix', ''), + 'key': backend_config.get('key', ''), + } + + +def fingerprint_pg(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'conn_str': backend_config.get('conn_str', ''), + 'schema_name': backend_config.get('schema_name', '') + } + + +def fingerprint_s3(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'endpoint': backend_config.get('endpoint') or env.get('AWS_S3_ENDPOINT', ''), + 'bucket': backend_config.get('bucket', ''), + 'workspace_key_prefix': backend_config.get('workspace_key_prefix', ''), + 'key': backend_config.get('key', ''), + } + + +def fingerprint_swift(backend_config: BackendConfig, env) -> dict[str, str]: + return { + 'auth_url': backend_config.get('auth_url') or env.get('OS_AUTH_URL', ''), + 'cloud': backend_config.get('cloud') or env.get('OS_CLOUD', ''), + 'region_name': backend_config.get('region_name') or env.get('OS_REGION_NAME', ''), + 'container': backend_config.get('container', ''), + 'state_name': backend_config.get('state_name', ''), + 'path': backend_config.get('path', ''), + 'tenant_id': backend_config.get('tenant_id') or env.get('OS_TENANT_NAME') or env.get('OS_PROJECT_NAME', ''), + 'project_domain_name': backend_config.get('project_domain_name') or env.get('OS_PROJECT_DOMAIN_NAME', ''), + 'project_domain_id': backend_config.get('project_domain_id') or env.get('OS_PROJECT_DOMAIN_ID', ''), + 'domain_name': backend_config.get('domain_name') or env.get('OS_USER_DOMAIN_NAME') or env.get('OS_PROJECT_DOMAIN_NAME') or env.get('OS_DOMAIN_NAME') or env.get('DEFAULT_DOMAIN'), + 'domain_id': backend_config.get('domain_id') or env.get('OS_PROJECT_DOMAIN_ID', ''), + 'default_domain': backend_config.get('default_domain') or env.get('OS_DEFAULT_DOMAIN', '') + } + + +def fingerprint_local(backend_config: BackendConfig, env) -> dict[str, str]: + fingerprint_inputs = { + 'path': backend_config.get('path', env['INPUT_PATH']) + } + + if 'workspace_dir' in backend_config: + fingerprint_inputs['workspace_dir'] = backend_config['workspace_dir'] + + return fingerprint_inputs + + +def fingerprint(backend_type: BackendType, backend_config: BackendConfig, env) -> bytes: + backends = { + 'remote': fingerprint_remote, + 'artifactory': fingerprint_artifactory, + 'azurerm': fingerprint_azurerm, + 'consul': fingerprint_consul, + 'cloud': fingerprint_cloud, + 'cos': fingerprint_cos, + 'etcd': fingerprint_etcd, + 'etcd3': fingerprint_etcd3, + 'gcs': fingerprint_gcs, + 'http': fingerprint_http, + 'kubernetes': fingerprint_kubernetes, + 'manta': fingerprint_manta, + 'oss': fingerprint_oss, + 'pg': fingerprint_pg, + 's3': fingerprint_s3, + 'swift': fingerprint_swift, + 'local': fingerprint_local, + } + + fingerprint_inputs = backends.get(backend_type, lambda c, e: c)(backend_config, env) + + debug(f'Backend fingerprint includes {fingerprint_inputs.keys()}') + + return canonicaljson.encode_canonical_json(fingerprint_inputs) diff --git a/image/src/github_pr_comment/comment.py b/image/src/github_pr_comment/comment.py new file mode 100644 index 00000000..281d35c5 --- /dev/null +++ b/image/src/github_pr_comment/comment.py @@ -0,0 +1,308 @@ +import json +import os +import re +from json import JSONDecodeError +from typing import Optional, Any + +from github_actions.api import IssueUrl, GithubApi, CommentUrl +from github_actions.debug import debug + +try: + collapse_threshold = int(os.environ['TF_PLAN_COLLAPSE_LENGTH']) +except (ValueError, KeyError): + collapse_threshold = 10 + +class TerraformComment: + """ + Represents a Terraform PR comment + + The comment must have been successfully created for an object of this type to have been created. + + A Terraform PR comment has a number of elements that are formatted such that they can later be parsed back into + an equivalent TerraformComment object. + + """ + + def __init__(self, *, issue_url: IssueUrl, comment_url: Optional[CommentUrl], headers: dict[str, str], description: str, summary: str, body: str, status: str): + self._issue_url = issue_url + self._comment_url = comment_url + self._headers = headers + self._description = description.strip() + self._summary = summary.strip() + self._body = body.strip() + self._status = status.strip() + + def __eq__(self, other): + if not isinstance(other, TerraformComment): + return NotImplemented + + return ( + self._issue_url == other._issue_url and + self._comment_url == other._comment_url and + self._headers == other._headers and + self._description == other._description and + self._summary == other._summary and + self._body == other._body and + self._status == other._status + ) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return f'TerraformComment(issue_url={self._issue_url!r}, comment_url={self._comment_url!r}, headers={self._headers!r}, description={self._description!r}, summary={self._summary!r}, body={self._body!r}, status={self._status!r})' + + @property + def comment_url(self) -> Optional[CommentUrl]: + return self._comment_url + + @comment_url.setter + def comment_url(self, comment_url: CommentUrl) -> None: + if self._comment_url is not None: + raise Exception('Can only set url for comments that don\'t exist yet') + self._comment_url = comment_url + + @property + def issue_url(self) -> IssueUrl: + return self._issue_url + + @property + def headers(self) -> dict[str, str]: + return self._headers + + @property + def description(self) -> str: + return self._description + + @property + def summary(self) -> str: + return self._summary + + @property + def body(self) -> str: + return self._body + + @property + def status(self) -> str: + return self._status + +def serialize(comment: TerraformComment) -> str: + return json.dumps({ + 'issue_url': comment.issue_url, + 'comment_url': comment.comment_url, + 'headers': comment.headers, + 'description': comment.description, + 'summary': comment.summary, + 'body': comment.body, + 'status': comment.status + }) + +def deserialize(s) -> TerraformComment: + j = json.loads(s) + + return TerraformComment( + issue_url=j['issue_url'], + comment_url=j['comment_url'], + headers=j['headers'], + description=j['description'], + summary=j['summary'], + body=j['body'], + status=j['status'] + ) + +def _format_comment_header(**kwargs) -> str: + return f'' + +def _parse_comment_header(comment_header: Optional[str]) -> dict[str, str]: + if comment_header is None: + return {} + + if header := re.match(r'^', comment_header): + try: + return json.loads(header['args']) + except JSONDecodeError: + return {} + + return {} + + +def _from_api_payload(comment: dict[str, Any]) -> Optional[TerraformComment]: + match = re.match(r''' + (?P\n)? + (?P.*) + \s* + (?:(?P.*?)\s*)? + ```(?:hcl)? + (?P.*) + ```\s* + + (?P.*) + ''', + comment['body'], + re.VERBOSE | re.DOTALL + ) + + if not match: + return None + + return TerraformComment( + issue_url=comment['issue_url'], + comment_url=comment['url'], + headers=_parse_comment_header(match.group('headers')), + description=match.group('description').strip(), + summary=match.group('summary').strip(), + body=match.group('body').strip(), + status=match.group('status').strip() + ) + + +def _to_api_payload(comment: TerraformComment) -> str: + details_open = False + hcl_highlighting = False + + if comment.body.startswith('Error'): + details_open = True + elif 'Plan:' in comment.body: + hcl_highlighting = True + num_lines = len(comment.body.splitlines()) + if num_lines < collapse_threshold: + details_open = True + + if comment.summary is None: + details_open = True + + header = _format_comment_header(**comment.headers) + + body = f'''{header} +{comment.description} + +{f'{comment.summary}' if comment.summary is not None else ''} + +```{'hcl' if hcl_highlighting else ''} +{comment.body} +``` + +''' + + if comment.status: + body += '\n' + comment.status + + return body + +def matching_headers(comment: TerraformComment, headers: dict[str, str]) -> bool: + """ + Does a comment have all the specified headers + + Additional headers may be present in the comment, they are ignored if not specified in the headers argument. + If a header should NOT be present in the comment, specify a header with a value of None + """ + + for header, value in headers.items(): + if value is None and header in comment.headers: + return False + + if value is not None and comment.headers.get(header) != value: + return False + + return True + +def find_comment(github: GithubApi, issue_url: IssueUrl, username: str, headers: dict[str, str], legacy_description: str) -> TerraformComment: + """ + Find a github comment that matches the given headers + + If no comment is found with the specified headers, tries to find a comment that matches the specified description instead. + This is in case the comment was made with an earlier version, where comments were matched by description only. + + If not existing comment is found a new TerraformComment object is returned which represents a PR comment yet to be created. + + :param github: The github api object to make requests with + :param issue_url: The issue to find the comment in + :param username: The user who made the comment + :param headers: The headers that must be present on the comment + :param legacy_description: The description that must be present on the comment, if not headers are found. + """ + + debug(f"Searching for comment with {headers=}") + + backup_comment = None + + for comment_payload in github.paged_get(issue_url): + if comment_payload['user']['login'] != username: + continue + + if comment := _from_api_payload(comment_payload): + + if comment.headers: + # Match by headers only + + if matching_headers(comment, headers): + debug(f'Found comment that matches headers {comment.headers=} ') + return comment + + debug(f"Didn't match comment with {comment.headers=}") + + else: + # Match by description only + + if comment.description == legacy_description and backup_comment is None: + debug(f'Found backup comment that matches legacy description {comment.description=}') + backup_comment = comment + + debug(f"Didn't match comment with {comment.description=}") + + if backup_comment is not None: + debug('Found comment matching legacy description') + + # Insert known headers into legacy comment + return TerraformComment( + issue_url=backup_comment.issue_url, + comment_url=backup_comment.comment_url, + headers={k: v for k, v in headers.items() if v is not None}, + description=backup_comment.description, + summary=backup_comment.summary, + body=backup_comment.body, + status=backup_comment.status + ) + + debug('No existing comment exists') + return TerraformComment( + issue_url=issue_url, + comment_url=None, + headers={k: v for k, v in headers.items() if v is not None}, + description='', + summary='', + body='', + status='' + ) + + +def update_comment( + github: GithubApi, + comment: TerraformComment, + *, + headers: dict[str, str] = None, + description: str = None, + summary: str = None, + body: str = None, + status: str = None +) -> TerraformComment: + + new_comment = TerraformComment( + issue_url=comment.issue_url, + comment_url=comment.comment_url, + headers=headers if headers is not None else comment.headers, + description=description if description is not None else comment.description, + summary=summary if summary is not None else comment.summary, + body=body if body is not None else comment.body, + status=status if status is not None else comment.status + ) + + if comment.comment_url is not None: + response = github.patch(comment.comment_url, json={'body': _to_api_payload(new_comment)}) + response.raise_for_status() + else: + response = github.post(comment.issue_url, json={'body': _to_api_payload(new_comment)}) + response.raise_for_status() + new_comment.comment_url = response.json()['url'] + + return new_comment diff --git a/tests/version/tfswitch/main.tf b/image/src/plan_summary/__init__.py similarity index 100% rename from tests/version/tfswitch/main.tf rename to image/src/plan_summary/__init__.py diff --git a/image/src/plan_summary/__main__.py b/image/src/plan_summary/__main__.py new file mode 100644 index 00000000..44c8f224 --- /dev/null +++ b/image/src/plan_summary/__main__.py @@ -0,0 +1,31 @@ +""" +Create plan summary actions outputs + +Creates the outputs: +- to_add +- to_change +- to_destroy + +Usage: + plan_summary +""" + +from __future__ import annotations + +import re +import sys +from github_actions.commands import output + +def main() -> None: + """Entrypoint for terraform-backend""" + + with open(sys.argv[1]) as f: + plan = f.read() + + if match := re.search(r'^Plan: (\d+) to add, (\d+) to change, (\d+) to destroy', plan, re.MULTILINE): + output('to_add', match[1]) + output('to_change', match[2]) + output('to_destroy', match[3]) + +if __name__ == '__main__': + sys.exit(main()) diff --git a/image/src/terraform/__init__.py b/image/src/terraform/__init__.py new file mode 100644 index 00000000..513bf03f --- /dev/null +++ b/image/src/terraform/__init__.py @@ -0,0 +1 @@ +"""Package for working with terraform.""" diff --git a/image/src/terraform/cloud.py b/image/src/terraform/cloud.py new file mode 100644 index 00000000..bafcd290 --- /dev/null +++ b/image/src/terraform/cloud.py @@ -0,0 +1,246 @@ +"""Module for interacting with Terraform Cloud/Enterprise.""" + +from __future__ import annotations + +import datetime +import os +from typing import Iterable, Optional, TypedDict, Any, cast + +import requests +from requests import Response + +from github_actions.debug import debug +from terraform.module import BackendConfig + +session = requests.Session() + + +class Workspace(TypedDict): + """A Terraform cloud workspace""" + id: str + attributes: dict[str, Any] + + +class CloudException(Exception): + """Raised when there is an error interacting with terraform cloud.""" + + def __init__(self, msg: str, response: Optional[Response]): + super().__init__(msg) + self.response = response + + +class TerraformCloudApi: + def __init__(self, host: str, token: str): + self._host = host + self._token = token + + def api_request(self, method: str, path: str, /, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> Response: + if headers is None: + headers = {} + + headers['Authorization'] = f'Bearer {self._token}' + + response = session.request(method, f'https://{self._host}/api/v2/{path}', headers=headers, **kwargs) + + debug(f'terraform cloud request url={response.url}') + debug(f'terraform cloud {response.status_code=}') + + if response.status_code == 401: + debug(str(response.content)) + raise CloudException('Terraform cloud operation failed: Unauthorized', response) + elif response.status_code == 429: + debug(str(response.content)) + raise CloudException('Terraform cloud rate limit reached', response) + elif not response.ok: + raise CloudException(f'Terraform cloud unexpected response code {response.status_code}', response) + + return response + + def get(self, path: str, **kwargs: Any) -> Response: + return self.api_request('GET', path, **kwargs) + + def delete(self, path: str, **kwargs: Any) -> Response: + return self.api_request('DELETE', path, **kwargs) + + def post(self, path: str, body: dict[str, Any], **kwargs: Any) -> Response: + return self.api_request('POST', path, headers={'Content-Type': 'application/vnd.api+json'}, json=body, **kwargs) + + def paged_get(self, path: str, **kwargs: Any) -> Iterable[Any]: + + page_num = 1 + while page_num is not None: + response = self.api_request('GET', path, params={'page[size]': 100, 'page[number]': page_num}, **kwargs) + + body = response.json() + yield from body.get('data', {}) + + page_num = body['meta']['pagination']['next-page'] + +def get_full_workspace_name(backend_config: BackendConfig, workspace_name: str) -> str: + + if 'prefix' in backend_config['workspaces']: + return backend_config['workspaces']['prefix'] + workspace_name + + elif 'name' in backend_config['workspaces']: + if backend_config['workspaces']['name'] != workspace_name: + raise CloudException(f'Only the configured workspace name {backend_config["workspaces"]["name"]!r} can be used, not {workspace_name!r}', None) + return workspace_name + + else: + return workspace_name + +def get_workspaces(backend_config: BackendConfig) -> Iterable[Workspace]: + """ + Return the workspaces that match the specified backend config. + + :param: The backend config to get workspaces for. + :return: The remote workspaces that match the backend config. + """ + + terraform_cloud = TerraformCloudApi(backend_config["hostname"], backend_config['token']) + + for workspace in terraform_cloud.paged_get( + f'/organizations/{backend_config["organization"]}/workspaces', + ): + + if 'name' in backend_config['workspaces']: + if workspace['attributes']['name'] == backend_config['workspaces']['name']: + yield workspace + elif 'prefix' in backend_config['workspaces']: + if workspace['attributes']['name'].startswith(backend_config['workspaces']['prefix']): + yield workspace + elif 'tags' in backend_config['workspaces']: + if all(tag in workspace['attributes']['tag-names'] for tag in backend_config['workspaces']['tags']): + yield workspace + + +def new_workspace(backend_config: BackendConfig, workspace_name: str) -> None: + """ + Create a new terraform cloud workspace. + + :param backend_config: Configuration for the backend to create the workspace in. + :param workspace_name: The name of the workspace to create. + :return: The new workspace. + """ + + full_workspace_name = get_full_workspace_name(backend_config, workspace_name) + + attributes = { + "name": full_workspace_name, + "resource-count": 0, + "updated-at": datetime.datetime.utcnow().isoformat() + 'Z', + } + + if version := os.environ.get('TERRAFORM_VERSION'): + attributes['terraform-version'] = version + + terraform_cloud = TerraformCloudApi(backend_config["hostname"], backend_config['token']) + + body = { + 'data': { + 'attributes': attributes, + 'type': 'workspaces' + } + } + + try: + response = terraform_cloud.post(f'/organizations/{backend_config["organization"]}/workspaces', body) + except CloudException as cloud_exception: + if cloud_exception.response is None: + raise + + content = cloud_exception.response.json() + + for error in content.get('errors', []): + if error.get('detail') != 'Name has already been taken': + raise + + # A workspace with this name already exists + debug(f'A workspace named {workspace_name!r} already exists') + + if 'tags' not in backend_config['workspaces']: + # We are done, the workspace exists + return None + + # For a cloud workspace, check the tags match + if get_workspace(backend_config, workspace_name): + # It has the correct tags + return None + + raise CloudException( + f'A workspace with the name {workspace_name!r} already exists, but without the correct tags. You must manually migrate this workspace by adding the correct tags.', + cloud_exception.response + ) + + raise + + workspace: dict[str, Any] = response.json()['data'] + + if 'tags' in backend_config['workspaces']: + terraform_cloud.post( + f'/workspaces/{workspace["id"]}/relationships/tags', + body={ + "data": [{ + "attributes": { + "name": tag, + }, + "type": "tags" + } for tag in sorted(backend_config['workspaces']['tags'])] + } + ) + + +def delete_workspace(backend_config: BackendConfig, workspace_name: str) -> None: + """ + Delete a terraform cloud workspace. + + :param backend_config: Configuration for the backend that contains the workspace. + :param workspace_name: The name of the workspace to delete. + """ + + full_workspace_name = get_full_workspace_name(backend_config, workspace_name) + + if 'tags' in backend_config['workspaces']: + # Try to get the workspace to check that it has the correct tags + if get_workspace(backend_config, workspace_name) is None: + raise CloudException(f'No such workspace {workspace_name!r} that matches the backend configuration', None) + + terraform_cloud = TerraformCloudApi(backend_config["hostname"], backend_config['token']) + + try: + terraform_cloud.delete(f'/organizations/{backend_config["organization"]}/workspaces/{full_workspace_name}') + except CloudException as cloud_exception: + if cloud_exception.response is not None and cloud_exception.response.status_code == 404: + raise CloudException(f'No such workspace {workspace_name!r} that matches the backend configuration', cloud_exception.response) + raise + + +def get_workspace(backend_config: BackendConfig, workspace_name: str) -> Optional[Workspace]: + """ + Get a remote workspace. + + :param backend_config: Configuration for the backend that contains the workspace. + :param workspace_name: The name of the workspace to get. + :return: The workspace, or None if there is no such workspace + """ + + full_workspace_name = get_full_workspace_name(backend_config, workspace_name) + + terraform_cloud = TerraformCloudApi(backend_config["hostname"], backend_config['token']) + + try: + response = terraform_cloud.get( + f'/organizations/{backend_config["organization"]}/workspaces/{full_workspace_name}' + ) + except CloudException as cloud_exception: + if cloud_exception.response is not None and cloud_exception.response.status_code == 404: + return None + raise + + workspace = response.json()['data'] + + if 'tags' in backend_config['workspaces']: + if not all(tag in workspace['attributes']['tag-names'] for tag in backend_config['workspaces']['tags']): + return None + + return cast(Workspace, workspace) diff --git a/image/src/terraform/download.py b/image/src/terraform/download.py new file mode 100644 index 00000000..1ee33a5c --- /dev/null +++ b/image/src/terraform/download.py @@ -0,0 +1,104 @@ +"""Module for downloading terraform executables.""" + +from __future__ import annotations + +import os.path +import platform +import sys +from pathlib import Path +from typing import TYPE_CHECKING +from urllib.request import urlretrieve +from zipfile import ZipFile + +from github_actions.debug import debug + +if TYPE_CHECKING: + from terraform.versions import Version + + +def get_platform() -> str: + """Return terraform's idea of the current platform name.""" + + p = sys.platform + if p.startswith('freebsd'): + return 'freebsd' + elif p.startswith('linux'): + return 'linux' + elif p.startswith('win32'): + return 'windows' + elif p.startswith('openbsd'): + return 'openbsd' + elif p.startswith('darwin'): + return 'darwin' + + raise Exception(f'Unknown platform {p}') + + +def get_arch() -> str: + """Return terraforms idea of the current architecture.""" + + a = platform.machine() + if a in ['x86_64', 'amd64']: + return 'amd64' + elif a in ['i386', 'i686', 'x86']: + return '386' + elif a.startswith('armv8') or a.startswith('aarch64'): + return 'arm64' + elif a.startswith('arm'): + return 'arm' + + raise Exception(f'Unknown arch {a}') + + +def download_version(version: Version, target_dir: Path) -> Path: + """ + Download the executable for the given version of terraform. + + The return value is the path to the executable + """ + + terraform_path = Path(target_dir, 'terraform') + + if os.path.exists(terraform_path): + return terraform_path + + debug(f'Downloading terraform {version}') + + local_filename, headers = urlretrieve( + f'https://releases.hashicorp.com/terraform/{version}/terraform_{version}_{get_platform()}_{get_arch()}.zip', + f'/tmp/terraform_{version}_linux_amd64.zip' + ) + + with ZipFile(local_filename) as f: + f.extract('terraform', target_dir) + + os.chmod(terraform_path, 755) + + return Path(os.path.abspath(terraform_path)) + + +def get_executable(version: Version) -> Path: + """ + Get the path to the specified terraform executable. + + Executables may be in any of the directories in TERRAFORM_BIN_DIR. + If executable doesn't exist, download it to the last directory in TERRAFORM_BIN_DIR. + Cache dirs are specified in the TERRAFORM_BIN_DIR env var as ':' separated paths. + The default is .terraform-bin-dir in the current directory. + + The return value is the path to the executable + """ + + cache_dirs = os.environ.get('TERRAFORM_BIN_DIR', '.terraform-bin-dir').split(':') + + download_dir = None + + for tf_dir in cache_dirs: + download_dir = Path(tf_dir, f'terraform_{version}') + terraform_path = os.path.join(download_dir, 'terraform') + if os.path.isfile(terraform_path): + return Path(os.path.abspath(terraform_path)) + + assert download_dir is not None + + return download_version(version, download_dir) diff --git a/image/src/terraform/exec.py b/image/src/terraform/exec.py new file mode 100644 index 00000000..14b4adad --- /dev/null +++ b/image/src/terraform/exec.py @@ -0,0 +1,25 @@ +"""Functions for executing terraform.""" + +from __future__ import annotations + +import os + +from github_actions.inputs import InitInputs + + +def init_args(inputs: InitInputs) -> list[str]: + """ + Generate arguments for the `terraform init` command from inputs + """ + + args = [] + + for path in inputs.get('INPUT_BACKEND_CONFIG_FILE', '').replace(',', '\n').splitlines(): + if path.strip(): + args.append(f'-backend-config={os.path.relpath(path.strip(), start=inputs["INPUT_PATH"])}') + + for config in inputs.get('INPUT_BACKEND_CONFIG', '').replace(',', '\n').splitlines(): + if stripped := config.strip(): + args.append(f'-backend-config={stripped}') + + return args diff --git a/image/src/terraform/hcl.py b/image/src/terraform/hcl.py new file mode 100644 index 00000000..192d6ab7 --- /dev/null +++ b/image/src/terraform/hcl.py @@ -0,0 +1,60 @@ +""" +Wraps python-hcl +""" + +import hcl2 # type: ignore +import sys +import subprocess +from pathlib import Path + +from github_actions.debug import debug + + +def try_load(path: Path) -> dict: + try: + with open(path) as f: + return hcl2.load(f) + except: + return {} + + +def is_loadable(path: Path) -> bool: + try: + subprocess.run( + [sys.executable, '-m', 'terraform.hcl', path], + timeout=10 + ) + except subprocess.TimeoutExpired: + debug('TimeoutExpired') + # We found a file that won't parse :( + return False + except: + # If we get an exception, we can still try and load it. + return True + + return True + + +def load(path: Path) -> dict: + if is_loadable(path): + return try_load(path) + + debug(f'Unable to load {path}') + raise ValueError(f'Unable to load {path}') + + +def loads(hcl: str) -> dict: + tmp_path = Path('/tmp/load_test.hcl') + + with open(tmp_path, 'w') as f: + f.write(hcl) + + if is_loadable(tmp_path): + return hcl2.loads(hcl) + + debug(f'Unable to load hcl') + raise ValueError(f'Unable to load hcl') + + +if __name__ == '__main__': + try_load(Path(sys.argv[1])) diff --git a/image/src/terraform/module.py b/image/src/terraform/module.py new file mode 100644 index 00000000..3381c447 --- /dev/null +++ b/image/src/terraform/module.py @@ -0,0 +1,253 @@ +"""Functions for handling terraform modules.""" + +from __future__ import annotations + +import os +from typing import Any, cast, NewType, Optional, TYPE_CHECKING, TypedDict + +import terraform.hcl + +from github_actions.debug import debug +from terraform.versions import Constraint + +if TYPE_CHECKING: + from pathlib import Path + +TerraformModule = NewType('TerraformModule', dict[str, list[dict[str, Any]]]) + + +class BackendConfigWorkspaces(TypedDict): + """A workspaces block from a terraform backend config.""" + name: str + prefix: str + tags: list[str] + + +class BackendConfig(TypedDict): + """The backend config for a terraform module.""" + hostname: str + organization: str + token: str + workspaces: BackendConfigWorkspaces + + +def merge(a: TerraformModule, b: TerraformModule) -> TerraformModule: + """Combine two terraform module objects into one.""" + + merged = cast(TerraformModule, {}) + + for key in set(a.keys() | b.keys()): + if isinstance(a.get(key, []), list) and isinstance(b.get(key, []), list): + if key not in merged: + merged[key] = [] + + merged[key].extend(a.get(key, [])) + merged[key].extend(b.get(key, [])) + else: + if key in a: + merged[key] = a[key] + if key in b: + merged[key] = b[key] + + return merged + + +def load_module(path: Path) -> TerraformModule: + """ + Load the terraform module. + + Every .tf file in the given directory is read and merged into one terraform module. + If any .tf file fails to parse, it is ignored. + """ + + module = cast(TerraformModule, {}) + + for file in os.listdir(path): + if not file.endswith('.tf'): + continue + + try: + tf_file = cast(TerraformModule, terraform.hcl.load(os.path.join(path, file))) + module = merge(module, tf_file) + except Exception as e: + # ignore tf files that don't parse + debug(f'Failed to parse {file}') + debug(str(e)) + + return module + + +def load_backend_config_file(path: Path) -> TerraformModule: + """Load a backend config file.""" + + return cast(TerraformModule, terraform.hcl.load(path)) + + +def read_cli_config(config: str) -> dict[str, str]: + """ + Read a CLI config file + + :param config: The CLI config file contents + """ + + hosts = {} + + config_hcl = terraform.hcl.loads(config) + + for credential in config_hcl.get('credentials', {}): + for cred_hostname, cred_conf in credential.items(): + if 'token' in cred_conf: + hosts[cred_hostname] = str(cred_conf['token']) + + return hosts + + +def get_cli_credentials(path: Path, hostname: str) -> Optional[str]: + """Get the terraform cloud token for a hostname from a cli credentials file.""" + + try: + with open(os.path.expanduser(path)) as f: + config = f.read() + except Exception: + debug('Failed to parse CLI Config file') + return None + + credentials = read_cli_config(config) + return credentials.get(hostname) + + +def get_version_constraints(module: TerraformModule) -> Optional[list[Constraint]]: + """Get the Terraform version constraint from the given module.""" + + for block in module.get('terraform', []): + if 'required_version' not in block: + continue + + try: + return [Constraint(c) for c in str(block['required_version']).split(',')] + except Exception: + debug('required_version constraint is malformed') + + return None + + +def get_remote_backend_config( + module: TerraformModule, + backend_config_files: str, + backend_config_vars: str, + cli_config_path: Path +) -> Optional[BackendConfig]: + """ + A complete backend config + + :param module: The terraform module to get the backend config from. At least a partial backend config must be present. + :param backend_config_files: Files containing additional backend config. + :param backend_config_vars: Additional backend config variables. + :param cli_config_path: A Terraform CLI config file to use. + """ + + found = False + backend_config = cast(BackendConfig, { + 'hostname': 'app.terraform.io', + 'workspaces': {} + }) + + for terraform in module.get('terraform', []): + for backend in terraform.get('backend', []): + if 'remote' not in backend: + return None + + found = True + if 'hostname' in backend['remote']: + backend_config['hostname'] = str(backend['remote']['hostname']) + + backend_config['organization'] = backend['remote'].get('organization') + backend_config['token'] = backend['remote'].get('token') + + if backend['remote'].get('workspaces', []): + backend_config['workspaces'] = backend['remote']['workspaces'][0] + + if not found: + return None + + def read_backend_files() -> None: + """Read backend config files specified in env var""" + for file in backend_config_files.replace(',', '\n').splitlines(): + for key, value in load_backend_config_file(Path(file)).items(): + backend_config[key] = value[0] if isinstance(value, list) else value # type: ignore + + def read_backend_vars() -> None: + """Read backend config values specified in env var""" + for line in backend_config_vars.replace(',', '\n').splitlines(): + key, value = line.split('=', maxsplit=1) + backend_config[key] = value # type: ignore + + read_backend_files() + read_backend_vars() + + if backend_config.get('token') is None and cli_config_path: + if token := get_cli_credentials(cli_config_path, str(backend_config['hostname'])): + backend_config['token'] = token + else: + debug(f'No token found for {backend_config["hostname"]}') + return backend_config + + return backend_config + + +def get_cloud_config(module: TerraformModule, cli_config_path: Path) -> Optional[BackendConfig]: + """ + Get a complete backend config for a module using terraform cloud + + :param module: The terraform module to get the cloud config from. + :param cli_config_path: A Terraform CLI config file to use. + """ + + found = False + backend_config = cast(BackendConfig, { + 'hostname': 'app.terraform.io', + 'workspaces': {} + }) + + for terraform in module.get('terraform', []): + for cloud in terraform.get('cloud', []): + + found = True + + if 'hostname' in cloud: + backend_config['hostname'] = cloud['hostname'] + + backend_config['organization'] = cloud.get('organization') + backend_config['token'] = cloud.get('token') + + if cloud.get('workspaces', []): + backend_config['workspaces'] = cloud['workspaces'][0] + + if not found: + return None + + if backend_config.get('token') is None and cli_config_path: + if token := get_cli_credentials(cli_config_path, backend_config['hostname']): + backend_config['token'] = token + + return backend_config + + +def get_backend_type(module: TerraformModule) -> Optional[str]: + """ + Get the backend type used by the module. + + :param module: The terraform module to get the backend for + :return: The name of the backend used by the module + """ + + for terraform in module.get('terraform', []): + for backend in terraform.get('backend', []): + for backend_type in backend: + return str(backend_type) + + for terraform in module.get('terraform', []): + if 'cloud' in terraform: + return 'remote' + + return 'local' diff --git a/image/src/terraform/versions.py b/image/src/terraform/versions.py new file mode 100644 index 00000000..7f0ea901 --- /dev/null +++ b/image/src/terraform/versions.py @@ -0,0 +1,232 @@ +"""Module for working with Terraform versions and version constraints.""" + +from __future__ import annotations + +import re +from functools import total_ordering +from typing import Any, cast, Iterable, Literal, Optional + +import requests + +session = requests.Session() + +ConstraintOperator = Literal['=', '!=', '>', '>=', '<', '<=', '~>'] + + +@total_ordering +class Version: + """ + A Terraform version. + + Versions are made up of major, minor & patch numbers, plus an optional pre_release string. + """ + + def __init__(self, version: str): + + match = re.match(r'(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P[\d\w]+))?', version) + if not match: + raise ValueError(f'Not a valid version {version}') + + self.major = int(match.group(1)) + self.minor = int(match.group(2)) + self.patch = int(match.group(3)) + self.pre_release = match.group(4) or '' + + def __repr__(self) -> str: + s = f'{self.major}.{self.minor}.{self.patch}' + + if self.pre_release: + s += f'-{self.pre_release}' + + return s + + def __hash__(self) -> int: + return hash(self.__repr__()) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Version): + return NotImplemented + + return self.major == other.major and self.minor == other.minor and self.patch == other.patch and self.pre_release == other.pre_release + + def __lt__(self, other: Any) -> bool: + if not isinstance(other, Version): + return NotImplemented + + if self.major != other.major: + return self.major < other.major + if self.minor != other.minor: + return self.minor < other.minor + if self.patch != other.patch: + return self.patch < other.patch + if self.pre_release != other.pre_release: + if self.pre_release == '': + return False + if other.pre_release == '': + return True + return self.pre_release < other.pre_release + + return False + + +class Constraint: + """A Terraform version constraint.""" + + def __init__(self, constraint: str): + if match := re.match(r'([=!<>~]*)(.*)', constraint.replace(' ', '')): + self.operator = cast(ConstraintOperator, match.group(1) or '=') + constraint = match.group(2) + else: + raise ValueError(f'Invalid version constraint {constraint}') + + if match := re.match(r'(?P\d+)(?:\.(?P\d+))?(?:\.(?P\d+))?(?:-(?P.*))?', constraint): + self.major = int(match.group('major')) + self.minor = int(match.group('minor')) if match.group('minor') else None + self.patch = int(match.group('patch')) if match.group('patch') else None + self.pre_release = match.group('pre_release') or '' + else: + raise ValueError(f'Invalid version constraint {constraint}') + + def __repr__(self) -> str: + s = f'{self.operator}{self.major}' + + if self.minor is not None: + s += f'.{self.minor}' + + if self.patch is not None: + s += f'.{self.patch}' + + if self.pre_release: + s += f'-{self.pre_release}' + + return s + + def __hash__(self) -> int: + return hash(self.__repr__()) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Constraint): + return NotImplemented + + return self.major == other.major and self.minor == other.minor and self.patch == other.patch and self.pre_release == other.pre_release and self.operator == other.operator + + def __lt__(self, other: Any) -> bool: + if not isinstance(other, Constraint): + return NotImplemented + + if self.major != other.major: + return self.major < other.major + if self.minor != other.minor: + return (self.minor or 0) < (other.minor or 0) + if self.patch != other.patch: + return (self.patch or 0) < (other.patch or 0) + if self.pre_release != other.pre_release: + if self.pre_release == '': + return False + if other.pre_release == '': + return True + return self.pre_release < other.pre_release + + operator_order = ['<', '<=', '=', '~>', '>=', '>'] + return operator_order.index(self.operator) < operator_order.index(other.operator) + + def is_allowed(self, version: Version) -> bool: + """Is the given version allowed by this constraint.""" + + def compare() -> int: + """ + Compare this version with the specified other version. + + If this version < other version, the return value is < 0 + If this version > other version, the return value is > 0 + If the versions are the same, the return value is 0 + """ + + if version.major != self.major: + return version.major - self.major + if version.minor != (self.minor or 0): + return version.minor - (self.minor or 0) + if version.patch != (self.patch or 0): + return version.patch - (self.patch or 0) + + if version.pre_release < self.pre_release: + return -1 + if version.pre_release > self.pre_release: + return 1 + + return 0 + + if self.operator == '=': + return compare() == 0 + if self.operator == '!=': + return compare() != 0 and not version.pre_release + if self.operator == '>': + return compare() > 0 and not version.pre_release + if self.operator == '>=': + return compare() >= 0 and not version.pre_release + if self.operator == '<': + return compare() < 0 and not version.pre_release + if self.operator == '<=': + return compare() <= 0 and not version.pre_release + if self.operator == '~>': + if version.pre_release: + return False + + if self.minor is None: + # ~> x + return version.major >= self.major + + if self.patch is None: + # ~> x.x + return version.major == self.major and version.minor >= self.minor + + # ~> x.x.x + return version.major == self.major and version.minor == self.minor and version.patch >= self.patch + +def latest_non_prerelease_version(versions: Iterable[Version]) -> Optional[Version]: + """Return the latest non prerelease version of the given versions.""" + + for v in sorted(versions, reverse=True): + if not v.pre_release: + return v + +def latest_version(versions: Iterable[Version]) -> Version: + """Return the latest version of the given versions.""" + + return sorted(versions, reverse=True)[0] + +def earliest_non_prerelease_version(versions: Iterable[Version]) -> Optional[Version]: + """Return the earliest non prerelease version of the given versions.""" + + for v in sorted(versions): + if not v.pre_release: + return v + +def earliest_version(versions: Iterable[Version]) -> Version: + """Return the earliest version of the given versions.""" + + return sorted(versions)[0] + + +def get_terraform_versions() -> Iterable[Version]: + """Return the currently available terraform versions.""" + + response = session.get('https://releases.hashicorp.com/terraform/') + response.raise_for_status() + + version_regex = re.compile(br'/(\d+\.\d+\.\d+(-[\d\w]+)?)') + + for version in version_regex.finditer(response.content): + yield Version(version.group(1).decode()) + + +def apply_constraints(versions: Iterable[Version], constraints: Iterable[Constraint]) -> Iterable[Version]: + """ + Apply the given version constraints. + + Returns the terraform versions that are allowed by all the given constraints + """ + + for version in versions: + if all(constraint.is_allowed(version) for constraint in constraints): + yield version diff --git a/image/src/terraform_backend/__init__.py b/image/src/terraform_backend/__init__.py new file mode 100644 index 00000000..106bacf2 --- /dev/null +++ b/image/src/terraform_backend/__init__.py @@ -0,0 +1 @@ +"""terraform-backend command""" diff --git a/image/src/terraform_backend/__main__.py b/image/src/terraform_backend/__main__.py new file mode 100644 index 00000000..e8cd6351 --- /dev/null +++ b/image/src/terraform_backend/__main__.py @@ -0,0 +1,21 @@ +""" +Output the backend type in use by the terraform module in the current path + +Usage: + terraform-backend +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from terraform.module import load_module, get_backend_type + + +def main() -> None: + """Entrypoint for terraform-backend""" + + module = load_module(Path(os.environ.get('INPUT_PATH', '.'))) + sys.stdout.write(f'{get_backend_type(module)}\n') diff --git a/image/src/terraform_cloud_state/__init__.py b/image/src/terraform_cloud_state/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/image/src/terraform_cloud_state/__main__.py b/image/src/terraform_cloud_state/__main__.py new file mode 100644 index 00000000..92024716 --- /dev/null +++ b/image/src/terraform_cloud_state/__main__.py @@ -0,0 +1,64 @@ +import os +import re +import sys +from pathlib import Path +from typing import Optional + +from github_actions.commands import output +from terraform.cloud import TerraformCloudApi +from terraform.module import BackendConfig +from terraform.module import load_module, get_remote_backend_config, get_cloud_config + + +def get_run_id(plan: str) -> Optional[str]: + if match := re.search(r'https://.*/(?P[^/]*)/runs/(?Prun-.*)$', plan, re.MULTILINE): + return match[2] + + +def get_cloud_json_plan(backend_config: BackendConfig, run_id: str) -> bytes: + terraform_cloud = TerraformCloudApi(backend_config["hostname"], backend_config['token']) + response = terraform_cloud.get(f'runs/{run_id}/plan/json-output') + response.raise_for_status() + return response.content + +def remote_run_id(): + if len(sys.argv) < 2: + sys.stderr.write('Usage: remote-run-id \n') + sys.exit(1) + + with open(sys.argv[1]) as f: + run_id = get_run_id(f.read()) + + if run_id is None: + sys.stderr.write('run_id not found in plan\n') + sys.exit(1) + + sys.stdout.write(run_id) + +def main(): + if len(sys.argv) < 2: + sys.stderr.write('Usage: terraform-cloud-state RUN_ID\n') + sys.exit(1) + + module = load_module(Path(os.environ.get('INPUT_PATH', '.'))) + + backend_config = get_remote_backend_config( + module, + backend_config_files=os.environ.get('INPUT_BACKEND_CONFIG_FILE', ''), + backend_config_vars=os.environ.get('INPUT_BACKEND_CONFIG', ''), + cli_config_path=Path('~/.terraformrc'), + ) + + if backend_config is None: + backend_config = get_cloud_config( + module, + cli_config_path=Path('~/.terraformrc'), + ) + + run_id = sys.argv[1] + + sys.stdout.write(get_cloud_json_plan(backend_config, run_id).decode()) + sys.stdout.write('\n') + +if __name__ == '__main__': + main() diff --git a/image/src/terraform_cloud_workspace/__init__.py b/image/src/terraform_cloud_workspace/__init__.py new file mode 100644 index 00000000..59d12359 --- /dev/null +++ b/image/src/terraform_cloud_workspace/__init__.py @@ -0,0 +1 @@ +"""terraform-cloud-workspace command""" diff --git a/image/src/terraform_cloud_workspace/__main__.py b/image/src/terraform_cloud_workspace/__main__.py new file mode 100644 index 00000000..07886f8b --- /dev/null +++ b/image/src/terraform_cloud_workspace/__main__.py @@ -0,0 +1,95 @@ +""" +Manage Terraform Cloud/Enterprise workspaces + +Usage: + terraform-cloud-workspace list + terraform-cloud-workspace new + terraform-cloud-workspace delete + +For whatever reason, the terraform workspace command needs an initialized backend. +When using the remote backend there may be no workspaces to initialize, so we are a bit stuck. + +This directly uses the cloud API to manage workspaces instead. + +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from terraform.cloud import delete_workspace, get_workspaces, new_workspace, CloudException +from terraform.module import load_module, get_remote_backend_config, get_cloud_config + + +def main() -> None: + """Entrypoint for terraform-cloud-workspace.""" + + if len(sys.argv) <= 1: + sys.stdout.write(f'{__doc__}\n') + sys.exit(1) + + module = load_module(Path(os.environ.get('INPUT_PATH', '.'))) + + backend_config = get_remote_backend_config( + module, + backend_config_files=os.environ.get('INPUT_BACKEND_CONFIG_FILE', ''), + backend_config_vars=os.environ.get('INPUT_BACKEND_CONFIG', ''), + cli_config_path=Path('~/.terraformrc'), + ) + + if backend_config is None: + backend_config = get_cloud_config( + module, + cli_config_path=Path('~/.terraformrc'), + ) + + if backend_config is None: + sys.stdout.write('Current directory doesn\'t use terraform cloud\n') + sys.exit(1) + + if backend_config.get('token') is None: + sys.stdout.write(f'No token found for {backend_config["hostname"]}\n') + sys.exit(1) + + if not backend_config.get('workspaces'): + sys.stdout.write('No required workspaces option found in backend block\n') + sys.exit(1) + + if len([k for k in backend_config['workspaces'] if k in ['tags', 'prefix', 'name']]) != 1: + sys.stdout.write('name or prefix required for remote backend. cloud config requires tags.\n') + sys.exit(1) + + try: + if sys.argv[1] == 'list': + for workspace in get_workspaces(backend_config): + if 'prefix' in backend_config['workspaces']: + sys.stdout.write(workspace['attributes']['name'][len(backend_config['workspaces']['prefix']):]) + else: + sys.stdout.write(workspace['attributes']['name']) + sys.stdout.write('\n') + sys.exit(0) + + if len(sys.argv) <= 2 or not sys.argv[2]: + sys.stdout.write(f'{__doc__}\n') + sys.exit(1) + + workspace_name = sys.argv[2] + + if sys.argv[1] == 'new': + new_workspace(backend_config, workspace_name) + sys.stdout.write(f'Created remote workspace {workspace_name}\n') + + elif sys.argv[1] == 'delete': + delete_workspace(backend_config, sys.argv[2]) + sys.stdout.write(f'Delete remote workspace {workspace_name}\n') + + else: + sys.stdout.write(f'{__doc__}\n') + sys.exit(1) + + except CloudException as cloud_exception: + sys.stderr.write(str(cloud_exception)) + sys.stderr.write('\n') + sys.exit(1) diff --git a/image/src/terraform_version/__init__.py b/image/src/terraform_version/__init__.py new file mode 100644 index 00000000..0bc49141 --- /dev/null +++ b/image/src/terraform_version/__init__.py @@ -0,0 +1 @@ +"""terraform-version command.""" diff --git a/image/src/terraform_version/__main__.py b/image/src/terraform_version/__main__.py new file mode 100644 index 00000000..77f2e86d --- /dev/null +++ b/image/src/terraform_version/__main__.py @@ -0,0 +1,119 @@ +"""Determine the version of terraform to use.""" + +from __future__ import annotations + +import os +import os.path +import sys +from pathlib import Path +from typing import Optional, cast + +from github_actions.debug import debug +from github_actions.env import ActionsEnv, GithubEnv +from github_actions.inputs import InitInputs +from terraform.download import get_executable +from terraform.module import load_module, get_backend_type +from terraform.versions import apply_constraints, get_terraform_versions, Version, Constraint, latest_non_prerelease_version +from terraform_version.asdf import try_read_asdf +from terraform_version.env import try_read_env +from terraform_version.local_state import try_read_local_state +from terraform_version.remote_state import get_backend_constraints, read_backend_config_vars, try_guess_state_version +from terraform_version.remote_workspace import try_get_remote_workspace_version +from terraform_version.required_version import try_get_required_version +from terraform_version.tfenv import try_read_tfenv +from terraform_version.tfswitch import try_read_tfswitch + + +def determine_version(inputs: InitInputs, cli_config_path: Path, actions_env: ActionsEnv, github_env: GithubEnv) -> Version: + """Determine the terraform version to use""" + + versions = list(get_terraform_versions()) + + module = load_module(Path(inputs.get('INPUT_PATH', '.'))) + + version: Optional[Version] + + if version := try_get_remote_workspace_version(inputs, module, cli_config_path, versions): + sys.stdout.write(f'Using remote workspace terraform version, which is set to {version!r}\n') + return version + + if version := try_get_required_version(module, versions): + sys.stdout.write(f'Using latest terraform version that matches the required_version constraints\n') + return version + + if version := try_read_tfswitch(inputs): + sys.stdout.write('Using terraform version specified in .tfswitchrc file\n') + return version + + if version := try_read_tfenv(inputs, versions): + sys.stdout.write('Using terraform version specified in .terraform-version file\n') + return version + + if version := try_read_asdf(inputs, github_env.get('GITHUB_WORKSPACE', '/'), versions): + sys.stdout.write('Using terraform version specified in .tool-versions file\n') + return version + + if version := try_read_env(actions_env, versions): + sys.stdout.write('Using latest terraform version that matches the TERRAFORM_VERSION constraints\n') + return version + + if inputs.get('INPUT_BACKEND_CONFIG', '').strip(): + # key=value form of backend config was introduced in 0.9.1 + versions = list(apply_constraints(versions, [Constraint('>=0.9.1')])) + + try: + backend_config = read_backend_config_vars(inputs) + versions = list(apply_constraints(versions, get_backend_constraints(module, backend_config))) + backend_type = get_backend_type(module) + except Exception as e: + debug('Failed to get backend config') + debug(str(e)) + return latest_non_prerelease_version(versions) + + if backend_type not in ['remote', 'local']: + if version := try_guess_state_version(inputs, module, versions): + sys.stdout.write('Using the same terraform version that wrote the existing remote state file\n') + return version + + if backend_type == 'local': + if version := try_read_local_state(Path(inputs.get('INPUT_PATH', '.'))): + sys.stdout.write('Using the same terraform version that wrote the existing local terraform.tfstate\n') + return version + + sys.stdout.write('Terraform version not specified, using the latest version\n') + return latest_non_prerelease_version(versions) + + +def switch(version: Version) -> None: + """ + Switch to the specified version of terraform. + + Updates the /usr/local/bin/terraform symlink to point to the specified version. + The version will be downloaded if it doesn't already exist. + """ + + sys.stdout.write(f'Switching to Terraform v{version}\n') + + target_path = get_executable(version) + + link_path = '/usr/local/bin/terraform' + if os.path.exists(link_path): + os.remove(link_path) + + os.symlink(target_path, link_path) + + +def main() -> None: + """Entrypoint for terraform-version.""" + + if len(sys.argv) > 1: + switch(Version(sys.argv[1])) + else: + version = determine_version( + cast(InitInputs, os.environ), + Path('~/.terraformrc'), + cast(ActionsEnv, os.environ), + cast(GithubEnv, os.environ) + ) + + switch(version) diff --git a/image/src/terraform_version/asdf.py b/image/src/terraform_version/asdf.py new file mode 100644 index 00000000..558428f7 --- /dev/null +++ b/image/src/terraform_version/asdf.py @@ -0,0 +1,43 @@ +"""asdf .tool-versions file support.""" + +from __future__ import annotations + +import os +import re +from typing import Iterable, Optional + +from github_actions.debug import debug +from github_actions.inputs import InitInputs +from terraform.versions import Version, latest_non_prerelease_version + + +def parse_asdf(tool_versions: str, versions: Iterable[Version]) -> Version: + """Return the version specified in an asdf .tool-versions file.""" + + for line in tool_versions.splitlines(): + if match := re.match(r'^\s*terraform\s+([^\s#]+)', line.strip()): + if match.group(1) == 'latest': + return latest_non_prerelease_version(v for v in versions if not v.pre_release) + return Version(match.group(1)) + + raise Exception('No version for terraform found in .tool-versions') + + +def try_read_asdf(inputs: InitInputs, workspace_path: str, versions: Iterable[Version]) -> Optional[Version]: + """Return the version from an asdf .tool-versions file if possible.""" + + module_path = os.path.abspath(inputs.get('INPUT_PATH', '.')) + + while module_path not in ['/', workspace_path]: + asdf_path = os.path.join(module_path, '.tool-versions') + + if os.path.isfile(asdf_path): + try: + with open(asdf_path) as f: + return parse_asdf(f.read(), versions) + except Exception as e: + debug(str(e)) + + module_path = os.path.dirname(module_path) + + return None diff --git a/image/src/terraform_version/backend_constraints.json b/image/src/terraform_version/backend_constraints.json new file mode 100644 index 00000000..41ed83fb --- /dev/null +++ b/image/src/terraform_version/backend_constraints.json @@ -0,0 +1,1166 @@ +{ + "artifactory": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "password": [ + ">=0.9.0" + ], + "repo": [ + ">=0.9.0" + ], + "subpath": [ + ">=0.9.0" + ], + "url": [ + ">=0.9.0" + ], + "username": [ + ">=0.9.0" + ] + }, + "environment_variables": { + "ARTIFACTORY_PASSWORD": [ + ">=0.9.0" + ], + "ARTIFACTORY_URL": [ + ">=0.9.0" + ], + "ARTIFACTORY_USERNAME": [ + ">=0.9.0" + ] + } + }, + "atlas": { + "terraform": [ + ">=0.9.0", + "<=0.14.11" + ], + "config_variables": { + "access_token": [ + ">=0.9.0", + "<=0.14.11" + ], + "address": [ + ">=0.9.0", + "<=0.14.11" + ], + "name": [ + ">=0.9.0", + "<=0.14.11" + ] + }, + "environment_variables": { + "ATLAS_ADDRESS": [ + ">=0.9.1", + "<=0.14.11" + ], + "ATLAS_TOKEN": [ + ">=0.9.0", + "<=0.14.11" + ] + } + }, + "azure": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "access_key": [ + ">=0.9.0" + ], + "arm_client_id": [ + ">=0.9.0", + "<=0.14.11" + ], + "arm_client_secret": [ + ">=0.9.0", + "<=0.14.11" + ], + "arm_subscription_id": [ + ">=0.9.0", + "<=0.14.11" + ], + "arm_tenant_id": [ + ">=0.9.0", + "<=0.14.11" + ], + "client_certificate_password": [ + ">=0.13.1" + ], + "client_certificate_path": [ + ">=0.13.1" + ], + "client_id": [ + ">=0.12.0" + ], + "client_secret": [ + ">=0.12.0" + ], + "container_name": [ + ">=0.9.0" + ], + "endpoint": [ + ">=0.12.0" + ], + "environment": [ + ">=0.9.0" + ], + "key": [ + ">=0.9.0" + ], + "lease_id": [ + ">=0.9.0", + "<=0.10.2" + ], + "metadata_host": [ + ">=0.13.1" + ], + "msi_endpoint": [ + ">=0.12.0" + ], + "resource_group_name": [ + ">=0.9.0" + ], + "sas_token": [ + ">=0.12.0" + ], + "snapshot": [ + ">=0.13.0" + ], + "storage_account_name": [ + ">=0.9.0" + ], + "subscription_id": [ + ">=0.12.0" + ], + "tenant_id": [ + ">=0.12.0" + ], + "use_azuread_auth": [ + ">=0.15.0" + ], + "use_microsoft_graph": [ + ">=1.1.0" + ], + "use_msi": [ + ">=0.12.0" + ] + }, + "environment_variables": { + "ARM_ACCESS_KEY": [ + ">=0.9.0" + ], + "ARM_CLIENT_CERTIFICATE_PASSWORD": [ + ">=0.13.1" + ], + "ARM_CLIENT_CERTIFICATE_PATH": [ + ">=0.13.1" + ], + "ARM_CLIENT_ID": [ + ">=0.9.0" + ], + "ARM_CLIENT_SECRET": [ + ">=0.9.0" + ], + "ARM_ENDPOINT": [ + ">=0.12.0" + ], + "ARM_ENVIRONMENT": [ + ">=0.9.0" + ], + "ARM_MSI_ENDPOINT": [ + ">=0.12.0" + ], + "ARM_SAS_TOKEN": [ + ">=0.12.0" + ], + "ARM_SNAPSHOT": [ + ">=0.13.0" + ], + "ARM_SUBSCRIPTION_ID": [ + ">=0.9.0" + ], + "ARM_TENANT_ID": [ + ">=0.9.0" + ], + "ARM_USE_AZUREAD": [ + ">=0.15.0" + ], + "ARM_USE_MSI": [ + ">=0.12.0" + ], + "ARM_LEASE_ID": [ + ">=0.9.0", + "<=0.10.2" + ] + } + }, + "consul": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "access_token": [ + ">=0.9.0" + ], + "address": [ + ">=0.9.0" + ], + "ca_file": [ + ">=0.10.3" + ], + "cert_file": [ + ">=0.10.3" + ], + "datacenter": [ + ">=0.9.0" + ], + "gzip": [ + ">=0.9.0" + ], + "http_auth": [ + ">=0.9.0" + ], + "key_file": [ + ">=0.10.3" + ], + "lock": [ + ">=0.9.0" + ], + "path": [ + ">=0.9.0" + ], + "scheme": [ + ">=0.9.0" + ] + }, + "environment_variables": { + "CONSUL_CACERT": [ + ">=0.10.3" + ], + "CONSUL_CLIENT_CERT": [ + ">=0.10.3" + ], + "CONSUL_CLIENT_KEY": [ + ">=0.10.3" + ], + "CONSUL_HTTP_ADDR": [ + ">=0.9.0" + ], + "CONSUL_HTTP_AUTH": [ + ">=0.9.0" + ], + "CONSUL_HTTP_SSL": [ + ">=0.9.0" + ], + "CONSUL_HTTP_TOKEN": [ + ">=0.9.0" + ] + } + }, + "cos": { + "terraform": [ + ">=0.12.21" + ], + "config_variables": { + "acl": [ + ">=0.12.21" + ], + "bucket": [ + ">=0.12.21" + ], + "encrypt": [ + ">=0.12.21" + ], + "key": [ + ">=0.12.21" + ], + "prefix": [ + ">=0.12.21" + ], + "region": [ + ">=0.12.21" + ], + "secret_id": [ + ">=0.12.21" + ], + "secret_key": [ + ">=0.12.21" + ] + }, + "environment_variables": { + "TENCENTCLOUD_REGION": [ + ">=0.12.21" + ], + "TENCENTCLOUD_SECRET_ID": [ + ">=0.12.21" + ], + "TENCENTCLOUD_SECRET_KEY": [ + ">=0.12.21" + ] + } + }, + "etcd": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "endpoints": [ + ">=0.9.0" + ], + "password": [ + ">=0.9.0" + ], + "path": [ + ">=0.9.0" + ], + "username": [ + ">=0.9.0" + ] + } + }, + "etcdv3": { + "terraform": [ + ">=0.10.8" + ], + "config_variables": { + "cacert_path": [ + ">=0.10.8" + ], + "cert_path": [ + ">=0.10.8" + ], + "endpoints": [ + ">=0.10.8" + ], + "key_path": [ + ">=0.10.8" + ], + "lock": [ + ">=0.10.8" + ], + "max_request_bytes": [ + ">=1.0.3" + ], + "password": [ + ">=0.10.8" + ], + "prefix": [ + ">=0.10.8" + ], + "username": [ + ">=0.10.8" + ] + }, + "environment_variables": { + "ETCDV3_PASSWORD": [ + ">=0.10.8" + ], + "ETCDV3_USERNAME": [ + ">=0.10.8" + ] + } + }, + "gcs": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "access_token": [ + ">=0.12.10" + ], + "bucket": [ + ">=0.9.0" + ], + "credentials": [ + ">=0.9.0" + ], + "encryption_key": [ + ">=0.11.2" + ], + "impersonate_service_account": [ + ">=0.14.0" + ], + "impersonate_service_account_delegates": [ + ">=0.14.0" + ], + "path": [ + ">=0.9.0", + "<=0.14.11" + ], + "prefix": [ + ">=0.11.0" + ], + "project": [ + ">=0.11.0", + "<=0.15.3" + ], + "region": [ + ">=0.11.0", + "<=0.15.3" + ] + }, + "environment_variables": { + "GOOGLE_BACKEND_CREDENTIALS": [ + ">=0.9.0" + ], + "GOOGLE_CREDENTIALS": [ + ">=0.9.0" + ], + "GOOGLE_ENCRYPTION_KEY": [ + ">=0.11.2" + ], + "GOOGLE_IMPERSONATE_SERVICE_ACCOUNT": [ + ">=0.14.0" + ] + } + }, + "http": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "address": [ + ">=0.9.0" + ], + "lock_address": [ + ">=0.10.3" + ], + "lock_method": [ + ">=0.10.3" + ], + "password": [ + ">=0.9.0" + ], + "retry_max": [ + ">=0.11.15", + "!=0.12.0", + "!=0.12.1" + ], + "retry_wait_max": [ + ">=0.11.15", + "!=0.12.0", + "!=0.12.1" + ], + "retry_wait_min": [ + ">=0.11.15", + "!=0.12.0", + "!=0.12.1" + ], + "skip_cert_verification": [ + ">=0.9.0" + ], + "unlock_address": [ + ">=0.10.3" + ], + "unlock_method": [ + ">=0.10.3" + ], + "update_method": [ + ">=0.10.3" + ], + "username": [ + ">=0.9.0" + ] + }, + "environment_variables": { + "TF_HTTP_ADDRESS": [ + ">=0.13.2" + ], + "TF_HTTP_LOCK_ADDRESS": [ + ">=0.13.2" + ], + "TF_HTTP_LOCK_METHOD": [ + ">=0.13.2" + ], + "TF_HTTP_PASSWORD": [ + ">=0.13.2" + ], + "TF_HTTP_RETRY_MAX": [ + ">=0.13.2" + ], + "TF_HTTP_RETRY_WAIT_MAX": [ + ">=0.13.2" + ], + "TF_HTTP_RETRY_WAIT_MIN": [ + ">=0.13.2" + ], + "TF_HTTP_UNLOCK_ADDRESS": [ + ">=0.13.2" + ], + "TF_HTTP_UNLOCK_METHOD": [ + ">=0.13.2" + ], + "TF_HTTP_UPDATE_METHOD": [ + ">=0.13.2" + ], + "TF_HTTP_USERNAME": [ + ">=0.13.2" + ] + } + }, + "kubernetes": { + "terraform": [ + ">=0.13.0" + ], + "config_variables": { + "client_certificate": [ + ">=0.13.0" + ], + "client_key": [ + ">=0.13.0" + ], + "cluster_ca_certificate": [ + ">=0.13.0" + ], + "config_context": [ + ">=0.13.0" + ], + "config_context_auth_info": [ + ">=0.13.0" + ], + "config_context_cluster": [ + ">=0.13.0" + ], + "config_path": [ + ">=0.13.0" + ], + "config_paths": [ + ">=1.1.0" + ], + "exec": [ + ">=0.13.0" + ], + "host": [ + ">=0.13.0" + ], + "in_cluster_config": [ + ">=0.13.0" + ], + "insecure": [ + ">=0.13.0" + ], + "labels": [ + ">=0.13.0" + ], + "load_config_file": [ + ">=0.13.0" + ], + "namespace": [ + ">=0.13.0" + ], + "password": [ + ">=0.13.0" + ], + "secret_suffix": [ + ">=0.13.0" + ], + "token": [ + ">=0.13.0" + ], + "username": [ + ">=0.13.0" + ] + }, + "environment_variables": { + "KUBE_CLIENT_CERT_DATA": [ + ">=0.13.0" + ], + "KUBE_CLIENT_KEY_DATA": [ + ">=0.13.0" + ], + "KUBE_CLUSTER_CA_CERT_DATA": [ + ">=0.13.0" + ], + "KUBE_CONFIG_PATH": [ + ">=1.1.0" + ], + "KUBE_CONFIG_PATHS": [ + ">=1.1.0" + ], + "KUBE_CTX": [ + ">=0.13.0" + ], + "KUBE_CTX_AUTH_INFO": [ + ">=0.13.0" + ], + "KUBE_CTX_CLUSTER": [ + ">=0.13.0" + ], + "KUBE_HOST": [ + ">=0.13.0" + ], + "KUBE_INSECURE": [ + ">=0.13.0" + ], + "KUBE_IN_CLUSTER_CONFIG": [ + ">=0.13.0" + ], + "KUBE_NAMESPACE": [ + ">=0.13.0" + ], + "KUBE_PASSWORD": [ + ">=0.13.0" + ], + "KUBE_TOKEN": [ + ">=0.13.0" + ], + "KUBE_USER": [ + ">=0.13.0" + ], + "KUBE_LOAD_CONFIG_FILE": [ + ">=0.13.0" + ] + } + }, + "manta": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "account": [ + ">=0.11.0" + ], + "insecure_skip_tls_verify": [ + ">=0.11.0" + ], + "key_id": [ + ">=0.11.0" + ], + "key_material": [ + ">=0.11.0" + ], + "objectName": [ + ">=0.9.0", + "<=0.11.15" + ], + "object_name": [ + ">=0.11.9" + ], + "path": [ + ">=0.9.0" + ], + "url": [ + ">=0.11.0" + ], + "user": [ + ">=0.11.2" + ] + }, + "environment_variables": { + "MANTA_URL": [ + ">=0.11.0" + ], + "SDC_ACCOUNT": [ + ">=0.11.0" + ], + "SDC_KEY_ID": [ + ">=0.11.0" + ], + "SDC_KEY_MATERIAL": [ + ">=0.11.0" + ], + "SDC_USER": [ + ">=0.11.2" + ], + "TRITON_ACCOUNT": [ + ">=0.11.0" + ], + "TRITON_KEY_ID": [ + ">=0.11.0" + ], + "TRITON_KEY_MATERIAL": [ + ">=0.11.0" + ], + "TRITON_USER": [ + ">=0.11.2" + ], + "TRITON_SKIP_TLS_VERIFY": [ + ">=0.11.0" + ] + } + }, + "oss": { + "terraform": [ + ">=0.12.2" + ], + "config_variables": { + "access_key": [ + ">=0.12.2" + ], + "acl": [ + ">=0.12.2" + ], + "assume_role": [ + ">=0.12.6" + ], + "assume_role_policy": [ + ">=1.1.0" + ], + "assume_role_role_arn": [ + ">=1.1.0" + ], + "assume_role_session_expiration": [ + ">=1.1.0" + ], + "assume_role_session_name": [ + ">=1.1.0" + ], + "bucket": [ + ">=0.12.2" + ], + "ecs_role_name": [ + ">=1.1.0" + ], + "encrypt": [ + ">=0.12.2" + ], + "endpoint": [ + ">=1.1.0" + ], + "key": [ + ">=0.12.2" + ], + "prefix": [ + ">=0.12.2" + ], + "profile": [ + ">=1.1.0" + ], + "region": [ + ">=0.12.2" + ], + "secret_key": [ + ">=0.12.2" + ], + "security_token": [ + ">=0.12.2" + ], + "shared_credentials_file": [ + ">=1.1.0" + ], + "sts_endpoint": [ + ">=1.1.0" + ], + "tablestore_endpoint": [ + ">=1.1.0" + ], + "tablestore_table": [ + ">=1.1.0" + ] + }, + "environment_variables": { + "ALICLOUD_ACCESS_KEY_ID": [ + ">=0.12.2" + ], + "ALICLOUD_ACCESS_KEY_SECRET": [ + ">=0.12.2" + ], + "ALICLOUD_ACCESS_KEY": [ + ">=0.12.2" + ], + "ALICLOUD_ASSUME_ROLE_ARN": [ + ">=1.1.0" + ], + "ALICLOUD_ASSUME_ROLE_SESSION_EXPIRATION": [ + ">=1.1.0" + ], + "ALICLOUD_ASSUME_ROLE_SESSION_NAME": [ + ">=1.1.0" + ], + "ALICLOUD_DEFAULT_REGION": [ + ">=0.12.2" + ], + "ALICLOUD_OSS_ENDPOINT": [ + ">=1.1.0" + ], + "ALICLOUD_PROFILE": [ + ">=1.1.0" + ], + "ALICLOUD_REGION": [ + ">=0.12.2" + ], + "ALICLOUD_SECRET_KEY": [ + ">=0.12.2" + ], + "ALICLOUD_SECURITY_TOKEN": [ + ">=0.12.2" + ], + "ALICLOUD_SHARED_CREDENTIALS_FILE": [ + ">=1.1.0" + ], + "ALICLOUD_STS_ENDPOINT": [ + ">=1.1.0" + ], + "ALICLOUD_TABLESTORE_ENDPOINT": [ + ">=1.1.0" + ], + "OSS_ENDPOINT": [ + ">=1.1.0" + ] + } + }, + "pg": { + "terraform": [ + ">=0.12.0" + ], + "config_variables": { + "conn_str": [ + ">=0.12.0" + ], + "schema_name": [ + ">=0.12.0" + ], + "skip_index_creation": [ + ">=0.14.0" + ], + "skip_schema_creation": [ + ">=0.12.8" + ], + "skip_table_creation": [ + ">=0.14.0" + ] + } + }, + "s3": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "access_key": [ + ">=0.9.0" + ], + "acl": [ + ">=0.9.0" + ], + "assume_role_duration_seconds": [ + ">=0.13.0" + ], + "assume_role_policy": [ + ">=0.9.3" + ], + "assume_role_policy_arns": [ + ">=0.13.0" + ], + "assume_role_tags": [ + ">=0.13.0" + ], + "assume_role_transitive_tag_keys": [ + ">=0.13.0" + ], + "bucket": [ + ">=0.9.0" + ], + "dynamodb_endpoint": [ + ">=0.11.14" + ], + "dynamodb_table": [ + ">=0.9.7" + ], + "encrypt": [ + ">=0.9.0" + ], + "endpoint": [ + ">=0.9.0" + ], + "external_id": [ + ">=0.9.3" + ], + "force_path_style": [ + ">=0.11.2" + ], + "iam_endpoint": [ + ">=0.11.14" + ], + "key": [ + ">=0.9.0" + ], + "kms_key_id": [ + ">=0.9.0" + ], + "lock_table": [ + ">=0.9.0", + "<=0.12.31" + ], + "max_retries": [ + ">=0.11.14" + ], + "profile": [ + ">=0.9.0" + ], + "region": [ + ">=0.9.0" + ], + "role_arn": [ + ">=0.9.0" + ], + "secret_key": [ + ">=0.9.0" + ], + "session_name": [ + ">=0.9.3" + ], + "shared_credentials_file": [ + ">=0.9.0" + ], + "skip_credentials_validation": [ + ">=0.10.8" + ], + "skip_get_ec2_platforms": [ + ">=0.10.8", + "<=0.12.31" + ], + "skip_metadata_api_check": [ + ">=0.10.8" + ], + "skip_region_validation": [ + ">=0.11.2" + ], + "skip_requesting_account_id": [ + ">=0.10.8", + "<=0.12.31" + ], + "sse_customer_key": [ + ">=0.12.8" + ], + "sts_endpoint": [ + ">=0.11.14" + ], + "token": [ + ">=0.9.0" + ], + "workspace_key_prefix": [ + ">=0.10.0" + ] + }, + "environment_variables": { + "AWS_ACCESS_KEY_ID": [ + ">=0.9.0" + ], + "AWS_DEFAULT_REGION": [ + ">=0.9.0" + ], + "AWS_IAM_ENDPOINT": [ + ">=0.11.14" + ], + "AWS_PROFILE": [ + ">=0.9.0" + ], + "AWS_REGION": [ + ">=0.9.0" + ], + "AWS_S3_ENDPOINT": [ + ">=0.9.0" + ], + "AWS_SECRET_ACCESS_KEY": [ + ">=0.9.0" + ], + "AWS_SESSION_TOKEN": [ + ">=0.9.0" + ], + "AWS_SSE_CUSTOMER_KEY": [ + ">=0.12.8" + ], + "AWS_STS_ENDPOINT": [ + ">=0.11.14" + ], + "AWS_DYNAMODB_ENDPOINT": [ + ">=0.11.14" + ] + } + }, + "swift": { + "terraform": [ + ">=0.9.0" + ], + "config_variables": { + "allow_reauth": [ + ">=0.13.0" + ], + "application_credential_id": [ + ">=0.12.2" + ], + "application_credential_name": [ + ">=0.12.2" + ], + "application_credential_secret": [ + ">=0.12.2" + ], + "archive_container": [ + ">=0.10.0" + ], + "archive_path": [ + ">=0.9.0" + ], + "auth_url": [ + ">=0.9.0" + ], + "cacert_file": [ + ">=0.9.0" + ], + "cert": [ + ">=0.9.0" + ], + "cloud": [ + ">=0.12.2" + ], + "container": [ + ">=0.10.0" + ], + "default_domain": [ + ">=0.12.2" + ], + "disable_no_cache_header": [ + ">=0.13.0" + ], + "domain_id": [ + ">=0.9.0" + ], + "domain_name": [ + ">=0.9.0" + ], + "endpoint_type": [ + ">=0.10.0" + ], + "expire_after": [ + ">=0.9.0" + ], + "insecure": [ + ">=0.9.0" + ], + "key": [ + ">=0.9.0" + ], + "lock": [ + ">=0.12.0" + ], + "max_retries": [ + ">=0.13.0" + ], + "password": [ + ">=0.9.0" + ], + "path": [ + ">=0.9.0" + ], + "project_domain_id": [ + ">=0.12.2" + ], + "project_domain_name": [ + ">=0.12.2" + ], + "region_name": [ + ">=0.9.0" + ], + "state_name": [ + ">=0.12.4" + ], + "swauth": [ + ">=0.13.0" + ], + "tenant_id": [ + ">=0.9.0" + ], + "tenant_name": [ + ">=0.9.0" + ], + "token": [ + ">=0.9.0" + ], + "user_domain_id": [ + ">=0.12.2" + ], + "user_domain_name": [ + ">=0.12.2" + ], + "user_id": [ + ">=0.9.0" + ], + "user_name": [ + ">=0.9.0" + ] + }, + "environment_variables": { + "OS_ALLOW_REAUTH": [ + ">=0.13.0" + ], + "DEFAULT_DOMAIN": [ + ">=0.9.0" + ], + "OS_AUTH_TOKEN": [ + ">=0.9.0" + ], + "OS_AUTH_URL": [ + ">=0.9.0" + ], + "OS_CACERT": [ + ">=0.9.0" + ], + "OS_CERT": [ + ">=0.9.0" + ], + "OS_DEFAULT_DOMAIN": [ + ">=0.10.0" + ], + "OS_DOMAIN_ID": [ + ">=0.9.0" + ], + "OS_DOMAIN_NAME": [ + ">=0.9.0" + ], + "OS_ENDPOINT_TYPE": [ + ">=0.10.0" + ], + "OS_INSECURE": [ + ">=0.9.0" + ], + "OS_KEY": [ + ">=0.9.0" + ], + "OS_PASSWORD": [ + ">=0.9.0" + ], + "OS_PROJECT_DOMAIN_ID": [ + ">=0.9.0" + ], + "OS_PROJECT_DOMAIN_NAME": [ + ">=0.9.0" + ], + "OS_PROJECT_ID": [ + ">=0.9.0" + ], + "OS_PROJECT_NAME": [ + ">=0.9.0" + ], + "OS_REGION_NAME": [ + ">=0.9.0" + ], + "OS_SWAUTH": [ + ">=0.13.0" + ], + "OS_TENANT_ID": [ + ">=0.9.0" + ], + "OS_TENANT_NAME": [ + ">=0.9.0" + ], + "OS_TOKEN": [ + ">=0.12.2" + ], + "OS_USERNAME": [ + ">=0.9.0" + ], + "OS_USER_DOMAIN_ID": [ + ">=0.9.0" + ], + "OS_USER_DOMAIN_NAME": [ + ">=0.9.0" + ], + "OS_USER_ID": [ + ">=0.9.0" + ] + } + } +} diff --git a/image/src/terraform_version/env.py b/image/src/terraform_version/env.py new file mode 100644 index 00000000..3efb2171 --- /dev/null +++ b/image/src/terraform_version/env.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Iterable, Optional + +from github_actions.debug import debug +from github_actions.env import ActionsEnv +from terraform.versions import Version, Constraint, apply_constraints, latest_non_prerelease_version + + +def try_read_env(actions_env: ActionsEnv, versions: Iterable[Version]) -> Optional[Version]: + if 'TERRAFORM_VERSION' not in actions_env: + return None + + constraint = actions_env['TERRAFORM_VERSION'] + + try: + valid_versions = list(apply_constraints(versions, [Constraint(c) for c in constraint.split(',')])) + if not valid_versions: + return None + return latest_non_prerelease_version(valid_versions) + + except Exception as exception: + debug(str(exception)) + + return None diff --git a/image/src/terraform_version/local_state.py b/image/src/terraform_version/local_state.py new file mode 100644 index 00000000..33e8277f --- /dev/null +++ b/image/src/terraform_version/local_state.py @@ -0,0 +1,35 @@ +import json +import os +from pathlib import Path +from typing import Optional + +from github_actions.debug import debug +from terraform.versions import Version + + +def read_local_state(module_dir: Path) -> Optional[Version]: + """Return the terraform version that wrote a local terraform.tfstate file.""" + + state_path = os.path.join(module_dir, 'terraform.tfstate') + + if not os.path.isfile(state_path): + return None + + try: + with open(state_path) as f: + state = json.load(f) + if state.get('serial') > 0: + return Version(state.get('terraform_version')) + except Exception as e: + debug(str(e)) + + return None + + +def try_read_local_state(module_dir: Path) -> Optional[Version]: + try: + return read_local_state(module_dir) + except Exception as e: + debug(str(e)) + + return None diff --git a/image/src/terraform_version/remote_state.py b/image/src/terraform_version/remote_state.py new file mode 100644 index 00000000..1cde9886 --- /dev/null +++ b/image/src/terraform_version/remote_state.py @@ -0,0 +1,230 @@ +"""Discover the terraform version that wrote an existing state file.""" + +from __future__ import annotations + +import importlib.resources +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Iterable, Optional, Tuple, Union + +from github_actions.debug import debug +from github_actions.inputs import InitInputs +from terraform.download import get_executable +from terraform.exec import init_args +from terraform.module import load_backend_config_file, TerraformModule +from terraform.versions import apply_constraints, Constraint, Version, earliest_version, earliest_non_prerelease_version + + +def read_backend_config_vars(init_inputs: InitInputs) -> dict[str, str]: + """Read any backend config from input variables.""" + + config: dict[str, str] = {} + + for path in init_inputs.get('INPUT_BACKEND_CONFIG_FILE', '').replace(',', '\n').splitlines(): + try: + config |= load_backend_config_file(Path(path)) # type: ignore + except Exception as e: + debug(f'Failed to load backend config file {path}') + debug(str(e)) + + for backend_var in init_inputs.get('INPUT_BACKEND_CONFIG', '').replace(',', '\n').splitlines(): + if match := re.match(r'(.*)\s*=\s*(.*)', backend_var): + config[match.group(1)] = match.group(2) + + return config + + +def backend_config(module: TerraformModule) -> Tuple[str, dict[str, Any]]: + """Return the backend config specified in the terraform module.""" + + for terraform in module.get('terraform', []): + for backend in terraform.get('backend', []): + for backend_type, config in backend.items(): + return backend_type, config + + return 'local', {} + + +def get_backend_constraints(module: TerraformModule, backend_config_vars: dict[str, str]) -> list[Constraint]: + """ + Get any version constraints we can glean from the backend configuration variables + + This should be enough to get a version of terraform that can init the backend and pull the state + """ + + backend_type, config = backend_config(module) + backend_constraints = json.loads(importlib.resources.read_binary('terraform_version', 'backend_constraints.json')) + + if backend_type == 'azurerm': + backend_type = 'azure' + + if backend_type not in backend_constraints: + return [] + + constraints = [Constraint(constraint) for constraint in backend_constraints[backend_type]['terraform']] + + for config_var in config | backend_config_vars: + if config_var not in backend_constraints[backend_type]['config_variables']: + continue + + for constraint in backend_constraints[backend_type]['config_variables'][config_var]: + constraints.append(Constraint(constraint)) + + for env_var in os.environ: + if env_var not in backend_constraints[backend_type]['environment_variables']: + continue + + for constraint in backend_constraints[backend_type]['environment_variables'][env_var]: + constraints.append(Constraint(constraint)) + + return constraints + + +def dump_backend_hcl(module: TerraformModule) -> str: + """Return a string representation of the backend config for the given module.""" + + def hcl_value(value: str | bool | int | float) -> str: + """The value as represented in hcl.""" + if isinstance(value, str): + return f'"{value}"' + elif value is True: + return 'true' + elif value is False: + return 'false' + else: + return str(value) + + backend_type, config = backend_config(module) + debug(f'{backend_type=}') + if backend_type == 'local': + return '' + + tf = 'terraform {\n backend "' + backend_type + '" {\n' + + for k, v in config.items(): + if isinstance(v, list): + tf += f' {k} {{\n' + for block in v: + for k, v in block.items(): + tf += f' {k} = {hcl_value(v)}\n' + tf += ' }\n' + else: + tf += f' {k} = {hcl_value(v)}\n' + + tf += ' }\n' + tf += '}\n' + + return tf + + +def try_init(terraform: Version, init_args: list[str], workspace: str, backend_tf: str) -> Optional[Union[Version, Constraint]]: + """ + Try and initialize the specified backend using the specified terraform version. + + Returns the information discovered from doing the init. This could be: + - Version: the version of terraform used to write the state + - Constraint: a constraint to apply to the available versions, that further narrows down to the version used to write the state + - None: There is no remote state + """ + + terraform_path = get_executable(terraform) + module_dir = tempfile.mkdtemp() + + with open(os.path.join(module_dir, 'terraform.tf'), 'w') as f: + f.write(backend_tf) + + # Here we go + result = subprocess.run( + [str(terraform_path), 'init'] + init_args, + env=os.environ | {'TF_INPUT': 'false', 'TF_WORKSPACE': workspace}, + capture_output=True, + cwd=module_dir + ) + debug(f'{result.args[:2]=}') + debug(f'{result.returncode=}') + debug(result.stdout.decode()) + debug(result.stderr.decode()) + + if result.returncode != 0: + if match := re.search(rb'state snapshot was created by Terraform v(.*),', result.stderr): + return Version(match.group(1).decode()) + elif b'does not support state version 4' in result.stderr: + return Constraint('>=0.12.0') + elif b'Failed to select workspace' in result.stderr: + return None + else: + debug(str(result.stderr)) + return None + + result = subprocess.run( + [terraform_path, 'state', 'pull'], + env=os.environ | {'TF_INPUT': 'false', 'TF_WORKSPACE': workspace}, + capture_output=True, + cwd=module_dir + ) + debug(f'{result.args=}') + debug(f'{result.returncode=}') + debug(f'{result.stdout.decode()=}') + debug(f'{result.stderr.decode()=}') + + if result.returncode != 0: + if b'does not support state version 4' in result.stderr: + return Constraint('>=0.12.0') + raise Exception(result.stderr) + + try: + state = json.loads(result.stdout.decode()) + if state['version'] == 4 and state['serial'] == 0 and not state.get('outputs', {}): + return None # This workspace has no state + + if b'no state' in result.stderr: + return None + + if terraform < Version('0.12.0'): + # terraform_version is reported correctly in state output + return Version(state['terraform_version']) + + # terraform_version is made up + except Exception as e: + debug(str(e)) + + # There is some state + return terraform + + +def guess_state_version(inputs: InitInputs, module: TerraformModule, versions: Iterable[Version]) -> Optional[Version]: + """Try and guess the terraform version that wrote the remote state file of the specified module.""" + + args = init_args(inputs) + backend_tf = dump_backend_hcl(module) + + candidate_versions = list(versions) + + while candidate_versions: + result = try_init(earliest_non_prerelease_version(candidate_versions), args, inputs.get('INPUT_WORKSPACE', 'default'), backend_tf) + if isinstance(result, Version): + return result + elif isinstance(result, Constraint): + candidate_versions = list(apply_constraints(candidate_versions, [result])) + elif result is None: + return None + else: + candidate_versions = list(apply_constraints(candidate_versions, [Constraint(f'!={earliest_version(candidate_versions)}')])) + + return None + + +def try_guess_state_version(inputs: InitInputs, module: TerraformModule, versions: Iterable[Version]) -> Optional[Version]: + """Try and guess the terraform version that wrote the remote state file of the specified module.""" + + try: + return guess_state_version(inputs, module, versions) + except Exception as e: + debug('Failed to find the terraform version from existing state') + debug(str(e)) + + return None diff --git a/image/src/terraform_version/remote_workspace.py b/image/src/terraform_version/remote_workspace.py new file mode 100644 index 00000000..e772ad8a --- /dev/null +++ b/image/src/terraform_version/remote_workspace.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Iterable, Optional + +from github_actions.debug import debug +from github_actions.inputs import InitInputs +from terraform.cloud import get_workspace +from terraform.module import TerraformModule, get_remote_backend_config, get_cloud_config +from terraform.versions import Version, latest_non_prerelease_version + + +def get_remote_workspace_version(inputs: InitInputs, module: TerraformModule, cli_config_path: Path, versions: Iterable[Version]) -> Optional[Version]: + """Get the terraform version set in a terraform cloud/enterprise workspace.""" + + backend_config = get_remote_backend_config( + module, + backend_config_files=inputs.get('INPUT_BACKEND_CONFIG_FILE', ''), + backend_config_vars=inputs.get('INPUT_BACKEND_CONFIG', ''), + cli_config_path=cli_config_path + ) + + if backend_config is None: + backend_config = get_cloud_config( + module, + cli_config_path=cli_config_path + ) + + if backend_config is None: + return None + + if workspace_info := get_workspace(backend_config, inputs['INPUT_WORKSPACE']): + version = str(workspace_info['attributes']['terraform-version']) + if version == 'latest': + return latest_non_prerelease_version(versions) + else: + return Version(version) + + return None + + +def try_get_remote_workspace_version(inputs: InitInputs, module: TerraformModule, cli_config_path: Path, versions: Iterable[Version]) -> Optional[Version]: + try: + return get_remote_workspace_version(inputs, module, cli_config_path, versions) + except Exception as exception: + debug('Failed to get terraform version from remote workspace') + debug(str(exception)) + + return None diff --git a/image/src/terraform_version/required_version.py b/image/src/terraform_version/required_version.py new file mode 100644 index 00000000..6aecba4e --- /dev/null +++ b/image/src/terraform_version/required_version.py @@ -0,0 +1,26 @@ +from typing import Optional, Iterable + +from github_actions.debug import debug +from terraform.module import get_version_constraints, TerraformModule +from terraform.versions import Version, apply_constraints, latest_non_prerelease_version + + +def get_required_version(module: TerraformModule, versions: Iterable[Version]) -> Optional[Version]: + constraints = get_version_constraints(module) + if constraints is None: + return None + + valid_versions = list(apply_constraints(versions, constraints)) + if not valid_versions: + raise RuntimeError(f'No versions of terraform match the required_version constraints {constraints}\n') + + return latest_non_prerelease_version(valid_versions) + + +def try_get_required_version(module: TerraformModule, versions: Iterable[Version]) -> Optional[Version]: + try: + return get_required_version(module, versions) + except Exception as e: + debug('Failed to get terraform version from required_version constraint') + + return None diff --git a/image/src/terraform_version/tfenv.py b/image/src/terraform_version/tfenv.py new file mode 100644 index 00000000..3842faeb --- /dev/null +++ b/image/src/terraform_version/tfenv.py @@ -0,0 +1,61 @@ +"""tfenv .terraform-version file support.""" + +from __future__ import annotations + +import os +import re +from typing import Iterable, Optional + +from github_actions.debug import debug +from github_actions.inputs import InitInputs +from terraform.versions import latest_version, Version, latest_non_prerelease_version + + +def parse_tfenv(terraform_version_file: str, versions: Iterable[Version]) -> Version: + """ + Return the version specified in the terraform version file + + :param terraform_version_file: The contents of a tfenv .terraform-version file. + :param versions: The available terraform versions + :return: The terraform version specified by the file + """ + + version = terraform_version_file.strip() + + if version == 'latest': + return latest_non_prerelease_version(v for v in versions if not v.pre_release) + + if version.startswith('latest:'): + version_regex = version.split(':', maxsplit=1)[1] + + matched = [v for v in versions if re.search(version_regex, str(v))] + + if not matched: + raise Exception(f'No terraform versions match regex {version_regex}') + + return latest_version(matched) + + return Version(version) + + +def try_read_tfenv(inputs: InitInputs, versions: Iterable[Version]) -> Optional[Version]: + """ + Return the terraform version specified by any .terraform-version file. + + :param inputs: The action inputs + :param versions: The available terraform versions + :returns: The terraform version specified by any .terraform-version file, which may be None. + """ + + tfenv_path = os.path.join(inputs.get('INPUT_PATH', '.'), '.terraform-version') + + if not os.path.exists(tfenv_path): + return None + + try: + with open(tfenv_path) as f: + return parse_tfenv(f.read(), versions) + except Exception as e: + debug(str(e)) + + return None diff --git a/image/src/terraform_version/tfswitch.py b/image/src/terraform_version/tfswitch.py new file mode 100644 index 00000000..74dcf2a5 --- /dev/null +++ b/image/src/terraform_version/tfswitch.py @@ -0,0 +1,43 @@ +"""tfswitch .tfswitchrc file support.""" + +from __future__ import annotations + +import os +from typing import Optional + +from github_actions.debug import debug +from github_actions.inputs import InitInputs +from terraform.versions import Version + + +def parse_tfswitch(tfswitch: str) -> Version: + """ + Return the terraform version specified by a tfswitch file + + :param tfswitch: The contents of a .tfswitch file + :return: The terraform version specified by the file + """ + + return Version(tfswitch.strip()) + + +def try_read_tfswitch(inputs: InitInputs) -> Optional[Version]: + """ + Return the terraform version specified by any .tfswitchrc file. + + :param inputs: The action inputs + :returns: The terraform version specified by the file, which may be None. + """ + + tfswitch_path = os.path.join(inputs.get('INPUT_PATH', '.'), '.tfswitchrc') + + if not os.path.exists(tfswitch_path): + return None + + try: + with open(tfswitch_path) as f: + return parse_tfswitch(f.read()) + except Exception as e: + debug(str(e)) + + return None diff --git a/image/tools/compact_plan.py b/image/tools/compact_plan.py index b7439f02..94b88e17 100755 --- a/image/tools/compact_plan.py +++ b/image/tools/compact_plan.py @@ -10,14 +10,18 @@ def compact_plan(input): for line in input: if not plan and ( + line.startswith('Terraform used the selected providers') or line.startswith('An execution plan has been generated and is shown below') or line.startswith('No changes') or - line.startswith('Error') + line.startswith('Error') or + line.startswith('Changes to Outputs:') ): plan = True if plan: - yield line + if not (line.startswith('Releasing state lock. This may take a few moments...') + or line.startswith('Acquiring state lock. This may take a few moments...')): + yield line else: buffer.append(line) diff --git a/image/tools/convert_output.py b/image/tools/convert_output.py index 862c5c2a..5fbff15f 100755 --- a/image/tools/convert_output.py +++ b/image/tools/convert_output.py @@ -4,6 +4,7 @@ import sys from typing import Dict, Iterable + def convert_to_github(outputs: Dict) -> Iterable[str]: for name, output in outputs.items(): @@ -30,12 +31,15 @@ def convert_to_github(outputs: Dict) -> Iterable[str]: yield f'::set-output name={name}::{value}' if __name__ == '__main__': + + input_string = sys.stdin.read() try: - outputs = json.load(sys.stdin) + outputs = json.loads(input_string) if not isinstance(outputs, dict): raise Exception('Unable to parse outputs') except: - exit(1) + sys.stderr.write(input_string) + raise for line in convert_to_github(outputs): print(line) diff --git a/image/tools/convert_validate_report.py b/image/tools/convert_validate_report.py index 13bf368d..9dc760f5 100755 --- a/image/tools/convert_validate_report.py +++ b/image/tools/convert_validate_report.py @@ -1,9 +1,10 @@ #!/usr/bin/python3 import json +import os.path import sys from typing import Dict, Iterable -import os.path + def relative_to_base(file_path: str, base_path: str): return os.path.normpath(os.path.join(base_path, file_path)) @@ -19,9 +20,17 @@ def convert_to_github(report: Dict, base_path: str) -> Iterable[str]: if 'start' in diag['range']: params['line'] = diag['range']['start']['line'] params['col'] = diag['range']['start']['column'] + if 'end' in diag['range']: + params['endLine'] = diag['range']['end']['line'] + params['endColumn'] = diag['range']['end']['column'] summary = diag['summary'].split('\n')[0] params = ','.join(f'{k}={v}' for k, v in params.items()) + + if summary == 'Module not installed': + # This is most likely because other errors prevented init from running properly, and not an error in itself. + continue + yield f"::{diag['severity']} {params}::{summary}" @@ -39,4 +48,8 @@ def convert_to_github(report: Dict, base_path: str) -> Iterable[str]: for line in convert_to_github(report, sys.argv[1]): print(line) - exit(0 if report.get('valid', False) is True else 1) + if report.get('valid', False) is True: + exit(0) + else: + print('::set-output name=failure-reason::validate-failed') + exit(1) diff --git a/image/tools/convert_version.py b/image/tools/convert_version.py index 763175e0..b10ce991 100755 --- a/image/tools/convert_version.py +++ b/image/tools/convert_version.py @@ -1,8 +1,9 @@ #!/usr/bin/python3 +import json import re import sys -from typing import Iterable +from typing import Dict, Iterable def convert_version(tf_output: str) -> Iterable[str]: @@ -37,10 +38,44 @@ def convert_version(tf_output: str) -> Iterable[str]: yield f'::set-output name={provider_name.strip()}::{provider_version.strip()}' +def convert_version_from_json(tf_output: Dict) -> Iterable[str]: + """ + Convert terraform version JSON output human readable output and GitHub actions output commands + + >>> tf_output = { + "terraform_version": "0.13.7", + "terraform_revision": "", + "provider_selections": { + "registry.terraform.io/hashicorp/random": "2.2.0" + }, + "terraform_outdated": true + } + >>> list(convert_version(tf_output)) + ['Terraform v0.13.7', + '::set-output name=terraform::0.13.7', + '+ provider registry.terraform.io/hashicorp/random v2.2.0', + '::set-output name=random::2.2.0'] + """ + + yield f'Terraform v{tf_output["terraform_version"]}' + yield f'::set-output name=terraform::{tf_output["terraform_version"]}' + + for path, version in tf_output['provider_selections'].items(): + name_match = re.match(r'(.*?)/(.*?)/(.*)', path) + name = name_match.group(3) if name_match else path + + yield f'+ provider {path} v{version}' + yield f'::set-output name={name}::{version}' + + if __name__ == '__main__': tf_output = sys.stdin.read() - print(tf_output) + try: + for line in convert_version_from_json(json.loads(tf_output)): + print(line) + except: + print(tf_output) - for line in convert_version(tf_output): - print(line) + for line in convert_version(tf_output): + print(line) diff --git a/image/tools/format_tf_credentials.py b/image/tools/format_tf_credentials.py new file mode 100755 index 00000000..c8c3a31d --- /dev/null +++ b/image/tools/format_tf_credentials.py @@ -0,0 +1,29 @@ +#!/usr/bin/python3 + +import os +import re +import sys + + +def format_credentials(input): + for line in input.splitlines(): + if line.strip() == '': + continue + + match = re.search(r'(?P.+?)\s*=\s*(?P.+)', line.strip()) + + if match: + yield f'''credentials "{match.group('host')}" {{ + token = "{match.group('token')}" +}} +''' + else: + raise ValueError('TERRAFORM_CLOUD_TOKENS environment variable should be "="') + +if __name__ == '__main__': + try: + for line in format_credentials(os.environ.get('TERRAFORM_CLOUD_TOKENS', '')): + sys.stdout.write(line) + except ValueError as e: + sys.stderr.write(str(e)) + exit(1) diff --git a/image/tools/github_comment_react.py b/image/tools/github_comment_react.py new file mode 100755 index 00000000..29244bc9 --- /dev/null +++ b/image/tools/github_comment_react.py @@ -0,0 +1,114 @@ +#!/usr/bin/python3 + +import datetime +import json +import os +import sys +from typing import NewType, Optional, TypedDict, cast + +import requests + +GitHubUrl = NewType('GitHubUrl', str) +CommentReactionUrl = NewType('CommentReactionUrl', GitHubUrl) + + +class GitHubActionsEnv(TypedDict): + """ + Environment variables that are set by the actions runner + """ + GITHUB_API_URL: str + GITHUB_TOKEN: str + GITHUB_EVENT_PATH: str + GITHUB_EVENT_NAME: str + GITHUB_REPOSITORY: str + GITHUB_SHA: str + + +job_tmp_dir = os.environ.get('JOB_TMP_DIR', '.') +step_tmp_dir = os.environ.get('STEP_TMP_DIR', '.') + +env = cast(GitHubActionsEnv, os.environ) + + +def github_session(github_env: GitHubActionsEnv) -> requests.Session: + """ + A request session that is configured for the github API + """ + session = requests.Session() + session.headers['authorization'] = f'token {github_env["GITHUB_TOKEN"]}' + session.headers['user-agent'] = 'terraform-github-actions' + session.headers['accept'] = 'application/vnd.github.v3+json' + return session + + +def github_api_request(method: str, *args, **kwargs) -> requests.Response: + response = github.request(method, *args, **kwargs) + + if 400 <= response.status_code < 500: + debug(str(response.headers)) + + try: + message = response.json()['message'] + + if response.headers['X-RateLimit-Remaining'] == '0': + limit_reset = datetime.datetime.fromtimestamp(int(response.headers['X-RateLimit-Reset'])) + sys.stdout.write(message) + sys.stdout.write(f' Try again when the rate limit resets at {limit_reset} UTC.\n') + exit(1) + + if message != 'Resource not accessible by integration': + sys.stdout.write(message) + sys.stdout.write('\n') + debug(response.content.decode()) + + except Exception: + sys.stdout.write(response.content.decode()) + sys.stdout.write('\n') + raise + + return response + + +def debug(msg: str) -> None: + sys.stderr.write(msg) + sys.stderr.write('\n') + + +def find_reaction_url(actions_env: GitHubActionsEnv) -> Optional[CommentReactionUrl]: + event_type = actions_env['GITHUB_EVENT_NAME'] + + if event_type not in ['issue_comment', 'pull_request_review_comment']: + return None + + try: + with open(actions_env['GITHUB_EVENT_PATH']) as f: + event = json.load(f) + + return event['comment']['reactions']['url'] + except Exception as e: + debug(str(e)) + + return None + + +def react(comment_reaction_url: CommentReactionUrl, reaction_type: str) -> None: + github_api_request('post', comment_reaction_url, json={'content': reaction_type}) + + +def main() -> None: + if len(sys.argv) < 2: + print(f'''Usage: + {sys.argv[0]} ''') + + debug(repr(sys.argv)) + + reaction_url = find_reaction_url(env) + if reaction_url is not None: + react(reaction_url, sys.argv[1]) + + +if __name__ == '__main__': + if 'GITHUB_TOKEN' not in env: + exit(0) + github = github_session(env) + main() diff --git a/image/tools/http_credential_actions_helper.py b/image/tools/http_credential_actions_helper.py new file mode 100755 index 00000000..58764db3 --- /dev/null +++ b/image/tools/http_credential_actions_helper.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 + +import os +import re +import sys +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional + + +@dataclass +class Credential: + hostname: str + path: List[str] + username: str + password: str + + +def git_credential(operation: str, attributes: Dict[str, str], credentials: List[Credential]): + att = attributes.copy() + sys.stderr.write(repr(att) + '\n') + + if operation != 'get': + return att + + if att.get('protocol') not in ['http', 'https']: + return att + + for cred in credentials: + if att.get('host') != cred.hostname: + continue + + if 'username' in att and att['username'] != cred.username: + continue + + if cred.path != split_path(att.get('path', ''))[:len(cred.path)]: + continue + + # Path matches + att['username'] = cred.username + att['password'] = cred.password + + path = '/'.join(cred.path) + sys.stderr.write(f'Using TERRAFORM_HTTP_CREDENTIALS for {cred.hostname}{f"/{path}" if path else ""}={cred.username}\n') + break + else: + path = att.get('path', '') + sys.stderr.write(f'No matching credentials found in TERRAFORM_HTTP_CREDENTIALS for {att.get("host")}{f"/{path}" if path else ""}\n') + + return att + +def split_path(path: Optional[str]) -> List[str]: + if path is None: + return [] + return [segment for segment in path.split('/') if segment] + + +def read_attributes(att_string: str) -> Dict[str, str]: + attributes = {} + for line in att_string.splitlines(): + match = re.match(r'^(.+?)=(.+)$', line) + if match: + attributes[match.group(1)] = match.group(2) + + return attributes + + +def write_attributes(attributes: Dict[str, str]) -> str: + return '\n'.join(f'{k}={v}' for k, v in attributes.items()) + + +def read_credentials(creds: str) -> Iterable[Credential]: + for line in creds.splitlines(): + match = re.match(r'(.*?)(/.*?)?=(.*?):(.*)', line.strip()) + if match: + yield Credential( + hostname=match.group(1).strip(), + path=split_path(match.group(2)), + username=match.group(3).strip(), + password=match.group(4).strip() + ) + +def netrc(credentials: List[Credential]) -> str: + s = '' + for cred in credentials: + s += f'machine {cred.hostname}\n' + s += f'login {cred.username}\n' + s += f'password {cred.password}\n' + return s + +def main(): + credentials = list(read_credentials(os.environ.get('TERRAFORM_HTTP_CREDENTIALS', ''))) + + if sys.argv[0] == '/usr/bin/netrc-credential-actions': + sys.stdout.write(netrc(credentials)) + else: + if len(sys.argv) != 2: + sys.stderr.write('This must be configured as a git credential helper\n') + exit(1) + + op = sys.argv[1] + + in_attributes = read_attributes(sys.stdin.read()) + out_attributes = git_credential(op, in_attributes, credentials) + sys.stdout.write(write_attributes(out_attributes)) + + +if __name__ == '__main__': + main() diff --git a/image/tools/latest_terraform_version.py b/image/tools/latest_terraform_version.py deleted file mode 100755 index 06e67ff8..00000000 --- a/image/tools/latest_terraform_version.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/python3 - -import re -from distutils.version import StrictVersion -from typing import List - -import requests - -version = re.compile(br'/(\d+\.\d+\.\d+)/') - - -def get_versions() -> List[StrictVersion]: - response = requests.get('https://releases.hashicorp.com/terraform/') - response.raise_for_status() - - versions = [StrictVersion(v.group(1).decode()) for v in version.finditer(response.content)] - return versions - - -def latest_version(versions: List[StrictVersion]): - latest = sorted(versions, reverse=True)[0] - return '.'.join([str(x) for x in latest.version]) - - -if __name__ == '__main__': - print(latest_version(get_versions())) - - -def test_version(): - versions = get_versions() - print(versions) - assert StrictVersion('0.12.28') in versions - assert StrictVersion('0.12.5') in versions - assert StrictVersion('0.11.14') in versions - assert StrictVersion('0.13.0') in versions diff --git a/image/tools/workspace_exists.py b/image/tools/workspace_exists.py index 35adb342..35d8a4d0 100755 --- a/image/tools/workspace_exists.py +++ b/image/tools/workspace_exists.py @@ -2,11 +2,19 @@ import sys + +def debug(msg: str) -> None: + for line in msg.splitlines(): + sys.stderr.write(f'::debug::{line}\n') + def workspace_exists(stdin, workspace: str) -> bool: for line in stdin: + debug(line) if line.strip().strip('*').strip() == workspace.strip(): + debug('workspace exists') return True + debug('workspace doesn\'t exist') return False if __name__ == '__main__': diff --git a/image/workflow_commands.sh b/image/workflow_commands.sh new file mode 100644 index 00000000..4ec5ead8 --- /dev/null +++ b/image/workflow_commands.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +## +# GitHub Actions workflow commands +# +# The processing of workflow commands is disabled, with these functions becoming the only way to +# use them. Processing can be enabled again using enable_workflow_commands + +## +# Send a string to the debug log +# +# This will be visible in the workflow log if ACTIONS_STEP_DEBUG workflow secret is set. +function debug_log() { + echo "::debug::" "$@" +} + +## +# Send a string to the error log +# +function error_log() { + echo "::error::" "$@" +} + +## +# Run a command and send the output to the debug log +# +# This will be visible in the workflow log if ACTIONS_STEP_DEBUG workflow secret is set. +function debug_cmd() { + local CMD_NAME + CMD_NAME="$*" + "$@" | while IFS= read -r line; do echo "::debug::${CMD_NAME}:${line}"; done +} + +## +# Print a file to the debug log +# +# This will be visible in the workflow log if ACTIONS_STEP_DEBUG workflow secret is set. +function debug_file() { + local FILE_PATH + FILE_PATH="$1" + + if [[ -s "$FILE_PATH" ]]; then + # File exists, and is not empty + sed "s|^|::debug::$FILE_PATH:|" "$FILE_PATH" + elif [[ -f "$FILE_PATH" ]]; then + # file exists but is empty + echo "::debug::$FILE_PATH is empty" + else + echo "::debug::$FILE_PATH does not exist" + fi +} + +## +# Set an output value +# +function set_output() { + local name + local value + + name="$1" + value="${*:2}" + + echo "::set-output name=${name}::${value}" +} + +## +# Start a log group +# +# All output between this and the next end_group will be collapsed into an expandable group +function start_group() { + echo "::group::$1" +} + +## +# End a log group +# +function end_group() { + echo "::endgroup::" +} + +## +# Enable to processing of workflow commands +# +function enable_workflow_commands() { + if [[ ! -v WORKFLOW_COMMAND_TOKEN ]]; then + echo "Tried to enable workflow commands, but they are already enabled" + exit 1 + fi + + echo "::${WORKFLOW_COMMAND_TOKEN}::" + unset WORKFLOW_COMMAND_TOKEN +} + +## +# Disable the processing of workflow commands +# +function disable_workflow_commands() { + if [[ -v WORKFLOW_COMMAND_TOKEN ]]; then + echo "Tried to disable workflow commands, but they are already disabled" + exit 1 + fi + + WORKFLOW_COMMAND_TOKEN=$(generate_command_token) + echo "::stop-commands::${WORKFLOW_COMMAND_TOKEN}" +} + +function generate_command_token() { + python3 -c "import random; import string; print(''.join(random.choice(string.ascii_lowercase) for i in range(64)))" +} diff --git a/terraform-apply/README.md b/terraform-apply/README.md index 551ab006..29b81281 100644 --- a/terraform-apply/README.md +++ b/terraform-apply/README.md @@ -11,7 +11,7 @@ This is to ensure that the action only applies changes that have been reviewed b You can instead set `auto_approve: true` which will generate a plan and apply it immediately, without looking for a plan attached to a PR. ## Demo -This a demo of the process for apply a terraform change using the [`dflook/terraform-plan`](https://github.com/dflook/terraform-github-actions/tree/master/terraform-plan) and [`dflook/terraform-apply`](https://github.com/dflook/terraform-github-actions/tree/master/terraform-apply) actions. +This a demo of the process for apply a terraform change using the [`dflook/terraform-plan`](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan) and [`dflook/terraform-apply`](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply) actions.

@@ -22,7 +22,7 @@ This a demo of the process for apply a terraform change using the [`dflook/terra To make best use of this action, require that the plan is always reviewed before merging the PR to approve. You can enforce this in github by going to the branch settings for the repo and enable protection for -the master branch: +the main branch: 1. Enable 'Require pull request reviews before merging' 2. Check 'Dismiss stale pull request approvals when new commits are pushed' @@ -31,12 +31,15 @@ the master branch: ## Inputs +These input values must be the same as any `terraform-plan` for the same configuration. (unless auto_approve: true) + * `path` - Path to the terraform configuration to apply + Path to the terraform root module to apply - Type: string - - Required + - Optional + - Default: The action workspace * `workspace` @@ -48,7 +51,7 @@ the master branch: * `label` - An friendly name for the environment the terraform configuration is for. + A friendly name for the environment the terraform configuration is for. This will be used in the PR comment for easy identification. It must be the same as the `label` used in the corresponding `terraform-plan` command. @@ -56,49 +59,95 @@ the master branch: - Type: string - Optional -* `var` +* `variables` + + Variables to set for the terraform plan. 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", + ] + ``` - Comma separated list of terraform vars to set + Variables set here override any given in `var_file`s. + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. - Type: string - Optional * `var_file` - Comma separated list of tfvars files to use. + 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 + ``` + + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. - Type: string - Optional * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + - Type: string - Optional -* `parallelism` +* `replace` - Limit the number of concurrent operations + List of resources to replace, one per line. - - Type: number + Only available with terraform versions that support replace (v0.15.2 onwards). + + ```yaml + with: + replace: | + kubernetes_secret.tls_cert_public + kubernetes_secret.tls_cert_private + ``` + + - Type: string - Optional - - Default: 10 * `target` - Comma separated list of targets to apply against, e.g. kubernetes_secret.tls_cert_public,kubernetes_secret.tls_cert_private + List of resources to apply, one per line. + The apply operation will be limited to these resources and their dependencies. - This only takes effect if auto_approve is also set to `true`. + ```yaml + with: + target: | + kubernetes_secret.tls_cert_public + kubernetes_secret.tls_cert_private + ``` - Type: string - Optional @@ -113,38 +162,202 @@ the master branch: - Optional - Default: false -## Environment Variables +* `parallelism` -### `GITHUB_TOKEN` + Limit the number of concurrent operations -The GitHub authorization token to use to fetch an approved plan from a PR. -The token provided by GitHub Actions can be used - it can be passed by -using the `${{ secrets.GITHUB_TOKEN }}` expression, e.g. + - Type: number + - Optional + - Default: The terraform default (10) -```yaml -env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` +* ~~`var`~~ + + > :warning: **Deprecated**: Use the `variables` input instead. + + Comma separated list of terraform vars to set. + + This is deprecated due to the following limitations: + - Only primitive types can be set with `var` - number, bool and string. + - String values may not contain a comma. + - Values set with `var` will be overridden by values contained in `var_file`s + - Does not work with the `remote` backend + + You can change from `var` to `variables` by putting each variable on a separate line and ensuring each string value is quoted. + + For example: + ```yaml + with: + var: instance_type=m5.xlarge,nat_type=instance + ``` + Becomes: + ```yaml + with: + variables: | + instance_type="m5.xlarge" + nat_type="instance" + ``` + + - Type: string + - Optional ## Outputs -An action output will be created for each output of the terraform configuration. +* `json_plan_path` -For example, with the terraform config: -```hcl -output "service_hostname" { - value = "example.com" -} -``` + This is the path to the generated plan in [JSON Output Format](https://www.terraform.io/docs/internals/json-format.html) + The path is relative to the Actions workspace. + + This is not available when using terraform 0.11 or earlier. + This also won't be set if the backend type is `remote` - Terraform does not support saving remote plans. + +* `text_plan_path` + + This is the path to the generated plan in a human-readable format. + The path is relative to the Actions workspace. + This won't be set if `auto_approve` is true while using a `remote` backend. + +* `failure-reason` + + When the job outcome is `failure`, this output may be set. The value may be one of: + + - `apply-failed` - The Terraform apply operation failed. + - `plan-changed` - The approved plan is no longer accurate, so the apply will not be attempted. + + 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. + +* `run_id` + + If the root module uses the `remote` or `cloud` backend in remote execution mode, this output will be set to the remote run id. + + - Type: string -Running this action will produce a `service_hostname` output with the same value. -See [terraform-output](https://github.com/dflook/terraform-github-actions/tree/master/terraform-output) for details. +* Terraform Outputs + + An action output will be created for each output of the terraform configuration. + + For example, with the terraform config: + ```hcl + output "service_hostname" { + value = "example.com" + } + ``` + + Running this action will produce a `service_hostname` output with the same value. + See [terraform-output](https://github.com/dflook/terraform-github-actions/tree/main/terraform-output) for details. + +## Environment Variables + +* `GITHUB_TOKEN` + + The GitHub authorization token to use to fetch an approved plan from a PR. + The token provided by GitHub Actions can be used - it can be passed by + using the `${{ secrets.GITHUB_TOKEN }}` expression, e.g. + + ```yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ``` + + The token provided by GitHub Actions will work with the default permissions. + The minimum permissions are `pull-requests: write`. + It will also likely need `contents: read` so the job can checkout the repo. + + You can also use a Personal Access Token which has the `repo` scope. + This must belong to the same user as the token used by the terraform-plan action + + - Type: string + - Optional + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - 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 + +* `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 ## Example usage ### Apply PR approved plans -This example workflow runs for every push to master. If the commit +This example workflow runs for every push to main. If the commit came from a PR that has been merged, applies the plan from the PR. ```yaml @@ -153,7 +366,7 @@ name: Apply on: push: branches: - - master + - main jobs: apply: @@ -173,7 +386,7 @@ jobs: ### Always apply changes -This example workflow runs for every push to master. +This example workflow runs for every push to main. Changes are planned and applied. ```yaml @@ -182,7 +395,7 @@ name: Apply on: push: branches: - - master + - main jobs: apply: @@ -224,14 +437,16 @@ jobs: with: path: my-terraform-config auto_approve: true - target: kubernetes_secret.tls_cert_public,kubernetes_secret.tls_cert_private + target: | + kubernetes_secret.tls_cert_public + kubernetes_secret.tls_cert_private ``` ### Applying a plan using a comment This workflow applies a plan on demand, triggered by someone commenting `terraform apply` on the PR. The plan is taken -from an existing comment generated by the [`dflook/terraform-plan`](https://github.com/dflook/terraform-github-actions/tree/master/terraform-plan) +from an existing comment generated by the [`dflook/terraform-plan`](https://github.com/dflook/terraform-github-actions/tree/main/terraform-plan) action. ```yaml @@ -241,7 +456,7 @@ on: [issue_comment] jobs: apply: - if: github.event.issue.pull_request && contains(github.event.comment.body, 'terraform apply') + if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, 'terraform apply') }} runs-on: ubuntu-latest name: Apply terraform plan env: @@ -257,3 +472,38 @@ jobs: with: path: my-terraform-config ``` + +This example retries the terraform apply operation if it fails. + +```yaml +name: Apply plan + +on: + push: + branches: + - main + +jobs: + plan: + runs-on: ubuntu-latest + name: Apply terraform plan + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: terraform apply + uses: dflook/terraform-apply@v1 + continue-on-error: true + id: first_try + with: + path: terraform + + - name: Retry failed apply + uses: dflook/terraform-apply@v1 + if: ${{ steps.first_try.outputs.failure-reason == 'apply-failed' }} + with: + path: terraform + auto_approve: true +``` diff --git a/terraform-apply/action.yaml b/terraform-apply/action.yaml index 1fe7f86a..5554583b 100644 --- a/terraform-apply/action.yaml +++ b/terraform-apply/action.yaml @@ -5,42 +5,62 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . workspace: description: Name of the terraform workspace required: false default: default backend_config: - description: Comma separated list of backend configs to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false default: "" backend_config_file: description: Path to a backend config file required: false default: "" + variables: + description: Variable definitions + required: false var: description: Comma separated list of vars to set, e.g. 'foo=bar' required: false default: "" + deprecationMessage: Use the variables input instead. var_file: - description: Comma separated list of var file paths + description: List of var file paths, one per line required: false default: "" parallelism: description: Limit the number of concurrent operations required: false - default: 0 + default: "0" label: description: A friendly name for this plan required: false default: "" auto_approve: description: Automatically approve and apply plan - default: false + required: false + default: "false" target: - description: "Comma separated list of targets to apply against, e.g. 'kubernetes_secret.tls_cert_public,kubernetes_secret.tls_cert_private' NOTE: this argument only takes effect if auto_approve is also set." + description: List of resources to target for the apply, one per line required: false default: "" + replace: + description: List of resources to replace if an update is required, one per line + required: false + default: "" + +outputs: + text_plan_path: + description: Path to a file in the workspace containing the generated plan in human readble format. This won't be set if the backend type is `remote` and `auto_approve` is `true` + json_plan_path: + description: Path to a file in the workspace containing the generated plan in JSON format. This won't be set if the backend type is `remote`. + failure-reason: + description: The reason for the build failure. May be `apply-failed` or `plan-changed`. + run_id: + description: If the root module uses the `remote` or `cloud` backend in remote execution mode, this output will be set to the remote run id. runs: using: docker diff --git a/terraform-check/README.md b/terraform-check/README.md index 2b6729b1..54ec5ca4 100644 --- a/terraform-check/README.md +++ b/terraform-check/README.md @@ -10,10 +10,11 @@ This is intended to run on a schedule to notify if manual changes to your infras * `path` - Path to the terraform configuration to check + Path to the terraform root module to check - Type: string - - Required + - Optional + - Default: The action workspace * `workspace` @@ -23,33 +24,65 @@ This is intended to run on a schedule to notify if manual changes to your infras - Optional - Default: `default` -* `var` +* `variables` - Comma separated list of terraform vars to set + Variables to set for the terraform plan. 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. + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. - Type: string - Optional * `var_file` - Comma separated list of tfvars files to use. + 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 + ``` + + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. - Type: string - Optional * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + - Type: string - Optional @@ -59,7 +92,110 @@ This is intended to run on a schedule to notify if manual changes to your infras - Type: number - Optional - - Default: 10 + - Default: The terraform default (10) + +* ~~`var`~~ + + > :warning: **Deprecated**: Use the `variables` input instead. + + Comma separated list of terraform vars to set + + - Type: string + - Optional + +## Outputs + +* `failure-reason` + + When the job outcome is `failure` because the there are outstanding changes to apply, this will be set to 'changes-to-apply'. + 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 a step when there are changes to apply. + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - 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 + +* `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 ## Example usage @@ -86,3 +222,31 @@ jobs: with: path: my-terraform-configuration ``` + +This example executes a run step only if there are changes to apply. + +```yaml +name: Check for infrastructure drift + +on: + schedule: + - cron: "0 8 * * *" + +jobs: + check_drift: + runs-on: ubuntu-latest + name: Check for drift of terraform configuration + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Check + uses: dflook/terraform-check@v1 + id: check + with: + path: my-terraform-configuration + + - name: Changes detected + if: ${{ failure() && steps.check.outputs.failure-reason == 'changes-to-apply' }} + run: echo "There are outstanding terraform changes to apply" +``` diff --git a/terraform-check/action.yaml b/terraform-check/action.yaml index d8f950d2..f74d5ea7 100644 --- a/terraform-check/action.yaml +++ b/terraform-check/action.yaml @@ -5,27 +5,34 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . workspace: description: Name of the terraform workspace required: false default: default backend_config: - description: Comma separated list of backend configs to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false + default: "" backend_config_file: - description: Path to a backend config file" + description: Path to a backend config file + required: false + default: "" + variables: + description: Variable definitions required: false var: description: Comma separated list of vars to set, e.g. 'foo=bar' required: false + deprecationMessage: Use the variables input instead. var_file: - description: Comma separated list of var file paths + description: List of var file paths, one per line required: false parallelism: description: Limit the number of concurrent operations required: false - default: 0 + default: "0" runs: using: docker diff --git a/terraform-destroy-workspace/README.md b/terraform-destroy-workspace/README.md index 01038176..871baf14 100644 --- a/terraform-destroy-workspace/README.md +++ b/terraform-destroy-workspace/README.md @@ -8,10 +8,11 @@ This action uses the `terraform destroy` command to destroy all resources in a t * `path` - Path to the terraform configuration + Path to the terraform root module - Type: string - - Required + - Optional + - Default: The action workspace * `workspace` @@ -20,33 +21,62 @@ This action uses the `terraform destroy` command to destroy all resources in a t - Type: string - Required -* `var` +* `variables` - Comma separated list of terraform vars to set + Variables to set for the terraform plan. 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` - Comma separated list of tfvars files to use. + 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 * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + - Type: string - Optional @@ -56,7 +86,110 @@ This action uses the `terraform destroy` command to destroy all resources in a t - Type: number - Optional - - Default: 10 + - Default: The terraform default (10) + +* ~~`var`~~ + + > :warning: **Deprecated**: Use the `variables` input instead. + + Comma separated list of terraform vars to set + + - Type: string + - Optional + +## Outputs + +* `failure-reason` + + When the job outcome is `failure` because the terraform destroy operation failed, this is set to `destroy-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 a step when the destroy fails. + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - 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 + +* `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 ## Example usage @@ -83,3 +216,36 @@ jobs: path: terraform workspace: ${{ github.head_ref }} ``` + +This example retries the terraform destroy operation if it fails. + +```yaml +name: Cleanup + +on: + pull_request: + types: [closed] + +jobs: + destroy_workspace: + runs-on: ubuntu-latest + name: Destroy terraform workspace + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: terraform destroy + uses: dflook/terraform-destroy-workspace@v1 + id: first_try + continue-on-error: true + with: + path: my-terraform-config + workspace: ${{ github.head_ref }} + + - name: Retry failed destroy + uses: dflook/terraform-destroy-workspace@v1 + if: ${{ steps.first_try.outputs.failure-reason == 'destroy-failed' }} + with: + path: my-terraform-config + workspace: ${{ github.head_ref }} +``` diff --git a/terraform-destroy-workspace/action.yaml b/terraform-destroy-workspace/action.yaml index 90d8c606..ec09aded 100644 --- a/terraform-destroy-workspace/action.yaml +++ b/terraform-destroy-workspace/action.yaml @@ -5,26 +5,33 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . workspace: description: Name of the terraform workspace required: true backend_config: - description: Comma separated list of backend configs to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false + default: "" backend_config_file: description: Path to a backend config file required: false + default: "" + variables: + description: Variable definitions + required: false var: description: Comma separated list of vars to set, e.g. 'foo=bar' required: false + deprecationMessage: Use the variables input instead. var_file: - description: Comma separated list of var file paths + description: List of var file paths, one per line required: false parallelism: description: Limit the number of concurrent operations required: false - default: 0 + default: "0" runs: using: docker diff --git a/terraform-destroy/README.md b/terraform-destroy/README.md index 1d1f4391..f5b38381 100644 --- a/terraform-destroy/README.md +++ b/terraform-destroy/README.md @@ -9,10 +9,11 @@ This action uses the `terraform destroy` command to destroy all resources in a t * `path` - Path to the terraform configuration + Path to the terraform root module - Type: string - - Required + - Optional + - Default: The action workspace * `workspace` @@ -22,33 +23,67 @@ This action uses the `terraform destroy` command to destroy all resources in a t - Optional - Default: `default` -* `var` +* `variables` - Comma separated list of terraform vars to set + Variables to set for the terraform destroy. 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. + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. + + > :warning: Secret values are not masked in the PR comment. Set a `label` to avoid revealing the variables in the PR. - Type: string - Optional * `var_file` - Comma separated list of tfvars files to use. + 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 + ``` + + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. - Type: string - Optional * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + - Type: string - Optional @@ -58,7 +93,110 @@ This action uses the `terraform destroy` command to destroy all resources in a t - Type: number - Optional - - Default: 10 + - Default: The terraform default (10) + +* ~~`var`~~ + + > :warning: **Deprecated**: Use the `variables` input instead. + + Comma separated list of terraform vars to set + + - Type: string + - Optional + +## Outputs + +* `failure-reason` + + When the job outcome is `failure` because the terraform destroy operation failed, this is set to `destroy-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 a step when the destroy fails. + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - 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 + +* `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 ## Example usage @@ -85,3 +223,36 @@ jobs: path: my-terraform-config workspace: ${{ github.head_ref }} ``` + +This example retries the terraform destroy operation if it fails. + +```yaml +name: Cleanup + +on: + pull_request: + types: [closed] + +jobs: + destroy_workspace: + runs-on: ubuntu-latest + name: Destroy terraform workspace + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: terraform destroy + uses: dflook/terraform-destroy@v1 + id: first_try + continue-on-error: true + with: + path: my-terraform-config + workspace: ${{ github.head_ref }} + + - name: Retry failed destroy + uses: dflook/terraform-destroy@v1 + if: ${{ steps.first_try.outputs.failure-reason == 'destroy-failed' }} + with: + path: my-terraform-config + workspace: ${{ github.head_ref }} +``` diff --git a/terraform-destroy/action.yaml b/terraform-destroy/action.yaml index 18148b91..cdcdddb6 100644 --- a/terraform-destroy/action.yaml +++ b/terraform-destroy/action.yaml @@ -5,27 +5,34 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . workspace: description: Name of the terraform workspace required: false default: default backend_config: - description: Comma separated list of backend configs to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false + default: "" backend_config_file: description: Path to a backend config file required: false + default: "" + variables: + description: Variable definitions + required: false var: description: Comma separated list of vars to set, e.g. 'foo=bar' required: false + deprecationMessage: Use the variables input instead. var_file: - description: Comma separated list of var file paths + description: List of var file paths, one per line required: false parallelism: description: Limit the number of concurrent operations required: false - default: 0 + default: "0" runs: using: docker diff --git a/terraform-fmt-check/README.md b/terraform-fmt-check/README.md index 58551493..368239e2 100644 --- a/terraform-fmt-check/README.md +++ b/terraform-fmt-check/README.md @@ -11,10 +11,79 @@ If any files are not correctly formatted a failing GitHub check will be added fo * `path` - Path to the terraform configuration + Path containing terraform files - Type: string - - Required + - Optional + - Default: The action workspace + +* `workspace` + + Terraform workspace to inspect when discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + + - Type: string + - Optional + +* `backend_config` + + List of terraform backend config values, one per line. This is used for discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` + + - Type: string + - Optional + +* `backend_config_file` + + List of terraform backend config files to use, one per line. This is used for discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + Paths should be relative to the GitHub Actions workspace + + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + + - Type: string + - Optional + +## Outputs + +* `failure-reason` + + When the job outcome is `failure` because the format check failed, this will be set to 'check-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 a step when the format check fails. + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + For the purpose of detecting the terraform version to use from a TFC/E backend. + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or other registries: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: | + app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + terraform.example.com=${{ secrets.TF_REGISTRY_TOKEN }} + ``` + + - Type: string + - Optional ## Example usage @@ -22,7 +91,28 @@ This example workflow runs on every push and fails if any of the terraform files are not formatted correctly. ```yaml -name: Check terraform file format +name: Check terraform file formatting + +on: [push] + +jobs: + check_format: + runs-on: ubuntu-latest + name: Check terraform file are formatted correctly + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: terraform fmt + uses: dflook/terraform-fmt-check@v1 + with: + path: my-terraform-config +``` + +This example executes a run step only if the format check failed. + +```yaml +name: Check terraform file formatting on: [push] @@ -36,6 +126,11 @@ jobs: - name: terraform fmt uses: dflook/terraform-fmt-check@v1 + id: fmt-check with: path: my-terraform-config + + - name: Wrong formatting found + if: ${{ failure() && steps.fmt-check.outputs.failure-reason == 'check-failed' }} + run: echo "terraform formatting check failed" ``` diff --git a/terraform-fmt-check/action.yaml b/terraform-fmt-check/action.yaml index 78ded3fb..d94bc0d6 100644 --- a/terraform-fmt-check/action.yaml +++ b/terraform-fmt-check/action.yaml @@ -5,7 +5,20 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . + workspace: + description: Name of the terraform workspace + required: false + default: default + backend_config: + description: List of backend config values to set, one per line + required: false + default: "" + backend_config_file: + description: Path to a backend config file + required: false + default: "" runs: using: docker diff --git a/terraform-fmt/README.md b/terraform-fmt/README.md index 74d5aa2a..1b6a2a1e 100644 --- a/terraform-fmt/README.md +++ b/terraform-fmt/README.md @@ -8,15 +8,76 @@ This action uses the `terraform fmt` command to reformat files in a directory in * `path` - Path to the terraform configuration + Path containing terraform files - Type: string - - Required + - Optional + - Default: The action workspace + +* `workspace` + + Terraform workspace to inspect when discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + + - Type: string + - Optional + +* `backend_config` + + List of terraform backend config values, one per line. This is used for discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` + + - Type: string + - Optional + +* `backend_config_file` + + List of terraform backend config files to use, one per line. This is used for discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + Paths should be relative to the GitHub Actions workspace + + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + + - Type: string + - Optional + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + For the purpose of detecting the terraform version to use from a TFC/E backend. + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or other registries: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: | + app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + terraform.example.com=${{ secrets.TF_REGISTRY_TOKEN }} + ``` + + - Type: string + - Optional ## Example usage This example automatically creates a pull request to fix any formatting -problems that get merged into the master branch. +problems that get merged into the main branch. ```yaml name: Fix terraform file formatting @@ -24,7 +85,7 @@ name: Fix terraform file formatting on: push: branches: - - master + - main jobs: format: diff --git a/terraform-fmt/action.yaml b/terraform-fmt/action.yaml index 13ec7d66..001b0191 100644 --- a/terraform-fmt/action.yaml +++ b/terraform-fmt/action.yaml @@ -5,7 +5,20 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . + workspace: + description: Name of the terraform workspace + required: false + default: default + backend_config: + description: List of backend config values to set, one per line + required: false + default: "" + backend_config_file: + description: Path to a backend config file + required: false + default: "" runs: using: docker diff --git a/terraform-new-workspace/README.md b/terraform-new-workspace/README.md index b87a72b6..b8831c1f 100644 --- a/terraform-new-workspace/README.md +++ b/terraform-new-workspace/README.md @@ -8,10 +8,11 @@ Creates a new terraform workspace. If the workspace already exists, succeeds wit * `path` - Path to the terraform configuration + Path to the terraform root module - Type: string - - Required + - Optional + - Default: The action workspace * `workspace` @@ -22,16 +23,112 @@ Creates a new terraform workspace. If the workspace already exists, succeeds wit * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + + - Type: string + - Optional + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - 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 + +* `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 diff --git a/terraform-new-workspace/action.yaml b/terraform-new-workspace/action.yaml index 65672b4c..02fe0ef5 100644 --- a/terraform-new-workspace/action.yaml +++ b/terraform-new-workspace/action.yaml @@ -5,16 +5,19 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . workspace: description: Name of the terraform workspace required: true backend_config: - description: Comma separated list of backend configs to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false + default: "" backend_config_file: - description: Path to a backend config file" + description: Path to a backend config file required: false + default: "" runs: using: docker diff --git a/terraform-output/README.md b/terraform-output/README.md index 006b891d..998ed586 100644 --- a/terraform-output/README.md +++ b/terraform-output/README.md @@ -8,10 +8,11 @@ Retrieve the root-level outputs from a terraform configuration. * `path` - Path to the terraform configuration + Path to the terraform root module - Type: string - - Required + - Optional + - Default: The action workspace * `workspace` @@ -23,16 +24,118 @@ Retrieve the root-level outputs from a terraform configuration. * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + + - Type: string + - Optional + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - 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 + +* `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_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 + 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 @@ -83,7 +186,7 @@ jobs: path: my-terraform-config - name: Print the hostname - run: echo "The terraform version was ${{ steps.tf-outputs.outputs.hostname }}" + run: echo "The hostname is ${{ steps.tf-outputs.outputs.hostname }}" ``` ### Complex output @@ -125,5 +228,5 @@ jobs: Which will print to the workflow log: ``` The vpc-id is vpc-01463b6b84e1454ce -The subnet-ids are is subnet-053008016a2c1768c,subnet-07d4ce437c43eba2f,subnet-0a5f8c3a20023b8c0 +The subnet-ids are subnet-053008016a2c1768c,subnet-07d4ce437c43eba2f,subnet-0a5f8c3a20023b8c0 ``` diff --git a/terraform-output/action.yaml b/terraform-output/action.yaml index c91f57a6..1d6e25e9 100644 --- a/terraform-output/action.yaml +++ b/terraform-output/action.yaml @@ -5,17 +5,20 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . workspace: description: Name of the terraform workspace required: false default: default backend_config: - description: Comma separated list of backend configs to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false + default: "" backend_config_file: description: Path to a backend config file required: false + default: "" runs: using: docker diff --git a/terraform-plan/README.md b/terraform-plan/README.md index 4e210b4b..a1a83103 100644 --- a/terraform-plan/README.md +++ b/terraform-plan/README.md @@ -9,19 +9,20 @@ If the triggering event relates to a PR it will add a comment on the PR containi

-The `GITHUB_TOKEN` environment variable is must be set for the PR comment to be added. +The `GITHUB_TOKEN` environment variable must be set for the PR comment to be added. The action can be run on other events, which prints the plan to the workflow log. -The [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/master/terraform-apply) action can be used to apply the generated plan. +The [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply) action can be used to apply the generated plan. ## Inputs * `path` - Path to the terraform configuration + Path to the terraform root module to apply - Type: string - - Required + - Optional + - Default: The action workspace * `workspace` @@ -33,61 +34,156 @@ The [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/ * `label` - An friendly name for the environment the terraform configuration is for. + A friendly name for the environment the terraform configuration is for. This will be used in the PR comment for easy identification. - It must be the same as the `label` used in the corresponding `terraform-apply` command. + If set, must be the same as the `label` used in the corresponding `terraform-apply` command. - Type: string - Optional -* `var` +* `variables` + + Variables to set for the terraform plan. 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. + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. - Comma separated list of terraform vars to set + > :warning: Secret values are not masked in the PR comment. Set a `label` to avoid revealing the variables in the PR. - Type: string - Optional * `var_file` - Comma separated list of tfvars files to use. + 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 + ``` + + This **can** be used with remote backends such as Terraform Cloud/Enterprise, with variables set in the remote workspace having precedence. - Type: string - Optional * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + - Type: string - Optional -* `parallelism` +* `replace` - Limit the number of concurrent operations + List of resources to replace, one per line. - - Type: number + Only available with terraform versions that support replace (v0.15.2 onwards). + + ```yaml + with: + replace: | + random_password.database + ``` + + - Type: string + - Optional + +* `target` + + List of resources to apply, one per line. + The plan will be limited to these resources and their dependencies. + + ```yaml + with: + target: | + kubernetes_secret.tls_cert_public + kubernetes_secret.tls_cert_private + ``` + + - Type: string - Optional - - Default: 10 * `add_github_comment` - The default is `true`, which adds a comment to the PR with the generated plan. + The default is `true`, which adds a comment to the PR with the results of the plan. + Set to `changes-only` to add a comment only when the plan indicates there are changes to apply. Set to `false` to disable the comment - the plan will still appear in the workflow log. - - Type: bool + - Type: string - Optional - Default: true +* `parallelism` + + Limit the number of concurrent operations + + - Type: number + - Optional + - Default: The terraform default (10) + +* ~~`var`~~ + + > :warning: **Deprecated**: Use the `variables` input instead. + + Comma separated list of terraform vars to set. + + This is deprecated due to the following limitations: + - Only primitive types can be set with `var` - number, bool and string. + - String values may not contain a comma. + - Values set with `var` will be overridden by values contained in `var_file`s + - Does not work with the `remote` backend + + You can change from `var` to `variables` by putting each variable on a separate line and ensuring each string value is quoted. + + For example: + ```yaml + with: + var: instance_type=m5.xlarge,nat_type=instance + ``` + Becomes: + ```yaml + with: + variables: | + instance_type="m5.xlarge" + nat_type="instance" + ``` + + - Type: string + - Optional + ## Environment Variables * `GITHUB_TOKEN` @@ -101,6 +197,116 @@ The [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` + The token provided by GitHub Actions will work with the default permissions. + The minimum permissions are `pull-requests: write`. + It will also likely need `contents: read` so the job can checkout the repo. + + You can also use a Personal Access Token which has the `repo` scope. + The GitHub user that owns the PAT will be the PR comment author. + + - Type: string + - Optional + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform 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 for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 + +* `TF_PLAN_COLLAPSE_LENGTH` + + When PR comments are enabled, the terraform output is included in a collapsable pane. + + If a terraform plan has fewer lines than this value, the pane is expanded + by default when the comment is displayed. + + ```yaml + env: + TF_PLAN_COLLAPSE_LENGTH: 30 + ``` + + - Type: integer + - Optional + - Default: 10 + +* `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 + ## Outputs * `changes` @@ -111,6 +317,48 @@ The [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/ resources would change. With terraform >=0.13 this is correctly set to 'true' whenever an apply needs to be run. + - Type: boolean + +* `json_plan_path` + + This is the path to the generated plan in [JSON Output Format](https://www.terraform.io/docs/internals/json-format.html) + The path is relative to the Actions workspace. + + This is not available when using terraform 0.11 or earlier. + + - Type: string + +* `text_plan_path` + + This is the path to the generated plan in a human-readable format. + The path is relative to the Actions workspace. + + - Type: string + +* `to_add` + + The number of resources that would be added by this plan. + + - Type: number + +* `to_change` + + The number of resources that would be changed by this plan. + + - Type: number + +* `to_destroy` + + The number of resources that would be destroyed by this plan. + + - Type: number + +* `run_id` + + If the root module uses the `remote` or `cloud` backend in remote execution mode, this output will be set to the remote run id. + + - Type: string + ## Example usage ### Automatically generating a plan @@ -139,6 +387,45 @@ jobs: path: my-terraform-config ``` +### A full example of inputs + +This example workflow demonstrates most of the available inputs: +- The environment variables are set at the workflow level. +- The PR comment will be labelled `production`, and the plan will use the `prod` workspace. +- Variables are read from `env/prod.tfvars`, with `turbo_mode` overridden to `true`. +- The backend config is taken from `env/prod.backend`, and the token is set from a secret. + +```yaml +name: PR Plan + +on: [pull_request] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TERRAFORM_CLOUD_TOKENS: terraform.example.com=${{ secrets.TF_REGISTRY_TOKEN }} + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + +jobs: + plan: + runs-on: ubuntu-latest + name: Create terraform plan + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: terraform plan + uses: dflook/terraform-plan@v1 + with: + path: my-terraform-config + label: production + workspace: prod + var_file: env/prod.tfvars + variables: | + turbo_mode=true + backend_config_file: env/prod.backend + backend_config: token=${{ secrets.BACKEND_TOKEN }} +``` + ### Generating a plan using a comment This workflow generates a plan on demand, triggered by someone @@ -152,7 +439,7 @@ on: [issue_comment] jobs: plan: - if: github.event.issue.pull_request && contains(github.event.comment.body, 'terraform plan') + if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, 'terraform plan') }} runs-on: ubuntu-latest name: Create terraform plan env: diff --git a/terraform-plan/action.yaml b/terraform-plan/action.yaml index 3710c0fe..dd662601 100644 --- a/terraform-plan/action.yaml +++ b/terraform-plan/action.yaml @@ -5,27 +5,42 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . workspace: description: Name of the terraform workspace required: false default: default backend_config: - description: Comma separated list of backend config values to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false + default: "" backend_config_file: description: Path to a backend config file required: false + default: "" + variables: + description: Variable definitions + required: false var: description: Comma separated list of vars to set, e.g. 'foo=bar' required: false + deprecationMessage: Use the variables input instead. var_file: - description: Comma separated list of var file paths + description: List of var file paths, one per line required: false parallelism: description: Limit the number of concurrent operations required: false - default: 0 + default: "0" + target: + description: List of resources to target for the plan, one per line + required: false + default: "" + replace: + description: List of resources to replace if an update is required, one per line + required: false + default: "" label: description: A friendly name for this plan required: false @@ -33,11 +48,23 @@ inputs: add_github_comment: description: Add the plan to a GitHub PR required: false - default: true + default: "true" outputs: changes: - description: If the plan changes any resources - 'true' or 'false' + description: If the generated plan would update any resources or outputs this is set to `true`, otherwise it's set to `false`. + to_add: + description: The number of resources that would be added by this plan + to_change: + description: The number of resources that would be changed by this plan + to_destroy: + description: The number of resources that would be destroyed by this plan + text_plan_path: + description: Path to a file in the workspace containing the generated plan in human readable format. + json_plan_path: + description: Path to a file in the workspace containing the generated plan in JSON format. + run_id: + description: If the root module uses the `remote` or `cloud` backend in remote execution mode, this output will be set to the remote run id. runs: using: docker diff --git a/terraform-remote-state/README.md b/terraform-remote-state/README.md index b70dd01c..7a6dd846 100644 --- a/terraform-remote-state/README.md +++ b/terraform-remote-state/README.md @@ -2,7 +2,7 @@ This is one of a suite of terraform related actions - find them at [dflook/terraform-github-actions](https://github.com/dflook/terraform-github-actions). -Retrieves the root-level outputs from a terraform remote state. +Retrieves the root-level outputs from a Terraform remote state. ## Inputs @@ -23,16 +23,50 @@ Retrieves the root-level outputs from a terraform remote state. * `backend_config` - Comma separated list of terraform backend config values. + List of terraform backend config values, one per line. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` - Type: string - Optional * `backend_config_file` - Comma separated list of terraform backend config files to use. + List of terraform backend config files to use, one per line. Paths should be relative to the GitHub Actions workspace + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + + - Type: string + - Optional + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform cloud hosts, of the form `=`. Multiple tokens may be specified, one per line. + These tokens may be used with the `remote` backend. + + e.g for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or other registries: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: | + app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + terraform.example.com=${{ secrets.TF_REGISTRY_TOKEN }} + ``` + - Type: string - Optional @@ -47,7 +81,7 @@ output "service_hostname" { } ``` Running this action will produce a `service_hostname` output with the same value. -See [terraform-output](https://github.com/dflook/terraform-github-actions/tree/master/terraform-output) for details. +See [terraform-output](https://github.com/dflook/terraform-github-actions/tree/main/terraform-output) for details. ## Example usage @@ -59,7 +93,7 @@ name: Send request on: push: branches: - - master + - main env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -75,7 +109,10 @@ jobs: id: remote-state with: backend_type: s3 - backend_config: bucket=terraform-github-actions,key=terraform-remote-state,region=eu-west-2 + backend_config: | + bucket=terraform-github-actions + key=terraform-remote-state + region=eu-west-2 - name: Send request run: | diff --git a/terraform-remote-state/action.yaml b/terraform-remote-state/action.yaml index ec532068..fde921f7 100644 --- a/terraform-remote-state/action.yaml +++ b/terraform-remote-state/action.yaml @@ -11,7 +11,7 @@ inputs: required: false default: default backend_config: - description: Comma separated list of backend config values to set, e.g. 'foo=bar' + description: List of backend config values to set, one per line required: false backend_config_file: description: Path to a backend config file diff --git a/terraform-validate/README.md b/terraform-validate/README.md index 816bc6db..75ff19de 100644 --- a/terraform-validate/README.md +++ b/terraform-validate/README.md @@ -17,10 +17,143 @@ If the terraform configuration is not valid, the build is failed. * `path` - Path to the terraform configuration + Path to the terraform root module - Type: string - - Required + - Optional + - Default: The action workspace + +* `workspace` + + Terraform workspace to use for the `terraform.workspace` value while validating. Note that for remote operations in Terraform Cloud/Enterprise, this is always `default`. + + Also used for discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + + - Type: string + - Optional + - Default: `default` + +* `backend_config` + + List of terraform backend config values, one per line. This is used for discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` + + - Type: string + - Optional + +* `backend_config_file` + + List of terraform backend config files to use, one per line. This is used for discovering the terraform version to use, if not otherwise specified. + See [dflook/terraform-version](https://github.com/dflook/terraform-github-actions/tree/main/terraform-version#terraform-version-action) for details. + Paths should be relative to the GitHub Actions workspace + + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + + - Type: string + - Optional + +## Outputs + +* `failure-reason` + + When the job outcome is `failure` because the validation failed, this will be set to 'validate-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 a step when the validate fails. + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform cloud hosts, of the form `=`. Multiple tokens may be specified, one per line. + These tokens may be used for fetching required modules from the registry, and discovering the terraform version to use from a TFC/E workspace. + + e.g for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - Type: string + - Optional + +* `TERRAFORM_PRE_RUN` + + A set of commands that will be run 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 + +* `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 ## Example usage @@ -43,3 +176,27 @@ jobs: with: path: my-terraform-config ``` + +This example executes a run step only if the validation failed. + +```yaml +on: [push] + +jobs: + validate: + runs-on: ubuntu-latest + name: Validate terraform + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: terraform validate + uses: dflook/terraform-validate@v1 + id: validate + with: + path: my-terraform-config + + - name: Validate failed + if: ${{ failure() && steps.validate.outputs.failure-reason == 'validate-failed' }} + run: echo "terraform validate failed" +``` diff --git a/terraform-validate/action.yaml b/terraform-validate/action.yaml index 5f3c8305..8c7c908d 100644 --- a/terraform-validate/action.yaml +++ b/terraform-validate/action.yaml @@ -5,7 +5,20 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . + workspace: + description: Name of the workspace to use for the `terraform.workspace` value while validating. + required: false + default: default + backend_config: + description: List of backend configs to set, one per line + required: false + default: "" + backend_config_file: + description: Path to a backend config file + required: false + default: "" runs: using: docker diff --git a/terraform-version/README.md b/terraform-version/README.md index 52057810..a02332ee 100644 --- a/terraform-version/README.md +++ b/terraform-version/README.md @@ -2,15 +2,20 @@ This is one of a suite of terraform related actions - find them at [dflook/terraform-github-actions](https://github.com/dflook/terraform-github-actions). -This action determines the terraform and provider versions to use for a terraform configuration directory. +This action determines the terraform and provider versions to use for a Terraform root module. + +The best way to specify the version is using a [`required_version`](https://www.terraform.io/docs/configuration/terraform.html#specifying-a-required-terraform-version) constraint. The version to use is discovered from the first of: -1. A [`required_version`](https://www.terraform.io/docs/configuration/terraform.html#specifying-a-required-terraform-version) - constraint in the terraform configuration. -2. A [tfswitch](https://warrensbox.github.io/terraform-switcher/) `.tfswitchrc` file -3. A [tfenv](https://github.com/tfutils/tfenv) `.terraform-version` file in path of the terraform - configuration. -4. The latest terraform version +1. The version set in the Terraform Cloud/Enterprise workspace if the module uses a `remote` backend or `cloud` configuration, and the remote workspace exists. +2. A [`required_version`](https://www.terraform.io/docs/configuration/terraform.html#specifying-a-required-terraform-version) + constraint in the terraform configuration. If the constraint is range, the latest matching version is used. +3. A [tfswitch](https://warrensbox.github.io/terraform-switcher/) `.tfswitchrc` file in the module path +4. A [tfenv](https://github.com/tfutils/tfenv) `.terraform-version` file in the module path +5. An [asdf](https://asdf-vm.com/) `.tool-versions` file in the module path or any parent path +6. A `TERRAFORM_VERSION` environment variable containing a [version constraint](https://www.terraform.io/language/expressions/version-constraints). If the constraint allows multiple versions, the latest matching version is used. +7. The Terraform version that created the current state file (best effort). +8. The latest terraform version The version of terraform and all required providers will be output to the workflow log. @@ -22,10 +27,136 @@ outputs yourself. * `path` - Path to the terraform configuration to apply + Path to the terraform root module + + - Type: string + - Optional + - Default: The action workspace + +* `workspace` + + The workspace to determine the Terraform version for. + + - Type: string + - Optional + - Default: `default` + +* `backend_config` + + List of terraform backend config values, one per line. + + This will be used to fetch the Terraform version set in the TFC/TFE workspace if using the `remote` backend. + For other backend types, this is used to fetch the version that most recently wrote to the terraform state. + + ```yaml + with: + backend_config: token=${{ secrets.BACKEND_TOKEN }} + ``` + + - Type: string + - Optional + +* `backend_config_file` + + List of terraform backend config files to use, one per line. + Paths should be relative to the GitHub Actions workspace + + This will be used to fetch the Terraform version set in the TFC/TFE workspace if using the `remote` backend. + For other backend types, this is used to fetch the version that most recently wrote to the terraform state. + + ```yaml + with: + backend_config_file: prod.backend.tfvars + ``` + + - Type: string + - Optional + +## Environment Variables + +* `TERRAFORM_CLOUD_TOKENS` + + API tokens for terraform cloud hosts, of the form `=`. Multiple tokens may be specified, one per line. + These tokens may be used for fetching required modules from the registry, and determining the terraform version set in the remote workspace. + + e.g for terraform cloud: + ```yaml + env: + TERRAFORM_CLOUD_TOKENS: app.terraform.io=${{ secrets.TF_CLOUD_TOKEN }} + ``` + + With Terraform Enterprise or 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 module sources. + + This should be in PEM format. + + For example: + ```yaml + env: + TERRAFORM_SSH_KEY: ${{ secrets.TERRAFORM_SSH_KEY }} + ``` + + - 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 + +* `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 - - Required + - Optional ## Outputs @@ -70,5 +201,5 @@ jobs: run: echo "The terraform version was ${{ steps.terraform-version.outputs.terraform }}" - name: Print aws provider version - run: echo "The terraform version was ${{ steps.terraform-version.outputs.aws }}" + run: echo "The aws provider version was ${{ steps.terraform-version.outputs.aws }}" ``` diff --git a/terraform-version/action.yaml b/terraform-version/action.yaml index 50ef1c2d..007b55d9 100644 --- a/terraform-version/action.yaml +++ b/terraform-version/action.yaml @@ -5,7 +5,20 @@ author: Daniel Flook inputs: path: description: Path to the terraform configuration - required: true + required: false + default: . + workspace: + description: Name of the terraform workspace to get the version for + required: false + default: default + backend_config: + description: List of backend config values to set, one per line + required: false + default: "" + backend_config_file: + description: Path to a backend config file + required: false + default: "" outputs: version: diff --git a/tests/apply/test.tfvars b/tests/apply/test.tfvars deleted file mode 100644 index 7d2b1f3a..00000000 --- a/tests/apply/test.tfvars +++ /dev/null @@ -1 +0,0 @@ -my_var_from_file="monkey" \ No newline at end of file diff --git a/tests/apply/vars/main.tf b/tests/apply/vars/main.tf deleted file mode 100644 index 63c74f93..00000000 --- a/tests/apply/vars/main.tf +++ /dev/null @@ -1,23 +0,0 @@ -resource "random_string" "my_string" { - length = 11 -} - -output "output_string" { - value = "the_string" -} - -variable "my_var" { - type = string -} - -variable "my_var_from_file" { - type = string -} - -output "from_var" { - value = var.my_var -} - -output "from_varfile" { - value = var.my_var_from_file -} diff --git a/tests/github_pr_comment/test_comment.py b/tests/github_pr_comment/test_comment.py new file mode 100644 index 00000000..61823233 --- /dev/null +++ b/tests/github_pr_comment/test_comment.py @@ -0,0 +1,140 @@ +import random +import string + +from github_pr_comment.comment import _format_comment_header, _parse_comment_header, TerraformComment, _to_api_payload, _from_api_payload + + +def test_comment_header(): + header_args = { + 'workspace_name': 'default', + 'backend_config': 'backend_config1' + } + + expected_header = '' + actual_header = _format_comment_header(**header_args) + assert actual_header == expected_header + + assert _parse_comment_header(expected_header) == header_args + + wonky_header = '' + assert _parse_comment_header(wonky_header) == header_args + + +def test_no_headers(): + issue_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + comment_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + status = 'Testing' + description = 'Hello, this is a description' + summary = 'Some changes' + body = '''An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status=status, + headers={}, + description=description, + summary=summary, + body=body + ) + + assert _from_api_payload({ + 'body': _to_api_payload(expected), + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_headers(): + issue_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + comment_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + status = 'Testing' + description = 'Hello, this is a description' + summary = 'Some changes' + body = '''An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +''' + headers = { + 'hello': 'first_header_value', + 'there': 'second_header_value' + } + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status=status, + headers=headers, + description=description, + summary=summary, + body=body + ) + + assert _from_api_payload({ + 'body': _to_api_payload(expected), + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_bad_description(): + issue_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + comment_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + status = 'Testing' + summary = 'Some changes' + body = '''blah blah body''' + description = 'crap -->\nqweqwesomething something
' + + headers = { + 'hello': 'first_header_value', + 'there': 'second_header_value' + } + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status=status, + headers=headers, + description=description, + summary=summary, + body=body + ) + + assert _from_api_payload({ + 'body': _to_api_payload(expected), + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_bad_body(): + issue_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + comment_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + status = 'Testing' + summary = 'Some changes' + description = '''blah blah description''' + body = 'qweqwe
something something ```' + + headers = { + 'hello': 'first_header_value', + 'there': 'second_header_value' + } + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status=status, + headers=headers, + description=description, + summary=summary, + body=body + ) + + assert _from_api_payload({ + 'body': _to_api_payload(expected), + 'url': comment_url, + 'issue_url': issue_url + }) == expected diff --git a/tests/github_pr_comment/test_legacy_comment.py b/tests/github_pr_comment/test_legacy_comment.py new file mode 100644 index 00000000..045b84ee --- /dev/null +++ b/tests/github_pr_comment/test_legacy_comment.py @@ -0,0 +1,457 @@ +""" +These test verify that _from_api_payload continues to correctly match pre-existing comments, without headers +""" + +import random +import string + +from github_pr_comment.comment import TerraformComment, _from_api_payload + +plan = '''An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy.''' + +issue_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) +comment_url = ''.join(random.choice(string.ascii_letters) for _ in range(10)) + +def test_path_only(): + payload = '''Terraform plan in __/test/terraform__ +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='Terraform plan in __/test/terraform__', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_nondefault_workspace(): + payload = '''Terraform plan in __/test/terraform__ in the __myworkspace__ workspace +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='Terraform plan in __/test/terraform__ in the __myworkspace__ workspace', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_variables_single_line(): + payload = '''Terraform plan in __/test/terraform__ +With variables: `var1="value"` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='Terraform plan in __/test/terraform__\nWith variables: `var1="value"`', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_variables_multi_line(): + payload = '''Terraform plan in __/test/terraform__
With variables + +```hcl +var1="value" +var2="value2" +``` +
+ +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__
With variables + +```hcl +var1="value" +var2="value2" +``` +
''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_var(): + payload = '''Terraform plan in __/test/terraform__ +With vars: `var1=value` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ +With vars: `var1=value`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_var_file(): + payload = '''Terraform plan in __/test/terraform__ +With var files: `vars.tf` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ +With var files: `vars.tf`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_backend_config(): + + payload = '''Terraform plan in __/test/terraform__ +With backend config: `bucket=test,key=backend` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ +With backend config: `bucket=test,key=backend`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_backend_config_bad_words(): + payload = '''Terraform plan in __/test/terraform__ +With backend config: `bucket=test,key=backend` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ +With backend config: `bucket=test,key=backend`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + +def test_target(): + payload = '''Terraform plan in __/test/terraform__ +Targeting resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ +Targeting resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + +def test_replace(): + payload = '''Terraform plan in __/test/terraform__ +Replacing resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ +Replacing resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_backend_config_file(): + payload = '''Terraform plan in __/test/terraform__ +With backend config files: `backend.tf` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ +With backend config files: `backend.tf`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_all(): + payload = '''Terraform plan in __/test/terraform__ in the __test__ workspace +Targeting resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private` +Replacing resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private` +With backend config: `bucket=mybucket` +With backend config files: `backend.tf` +With vars: `myvar=hello` +With var files: `vars.tf` +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan in __/test/terraform__ in the __test__ workspace +Targeting resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private` +Replacing resources: `kubernetes_secret.tls_cert_public[0]`, `kubernetes_secret.tls_cert_private` +With backend config: `bucket=mybucket` +With backend config files: `backend.tf` +With vars: `myvar=hello` +With var files: `vars.tf`''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected + + +def test_label(): + payload = '''Terraform plan for __test_label__ +
+Plan: 1 to add, 0 to change, 0 to destroy. + +```hcl +An execution plan has been generated and is shown below. +... +Plan: 1 to add, 0 to change, 0 to destroy. +``` +
+ +Testing''' + + expected = TerraformComment( + issue_url=issue_url, + comment_url=comment_url, + status='Testing', + headers={}, + description='''Terraform plan for __test_label__''', + summary='Plan: 1 to add, 0 to change, 0 to destroy.', + body=plan + ) + + assert _from_api_payload({ + 'body': payload, + 'url': comment_url, + 'issue_url': issue_url + }) == expected diff --git a/tests/github_pr_comment/test_summary.py b/tests/github_pr_comment/test_summary.py new file mode 100644 index 00000000..179a6898 --- /dev/null +++ b/tests/github_pr_comment/test_summary.py @@ -0,0 +1,160 @@ +from github_pr_comment.__main__ import create_summary + + +def test_summary_plan_11(): + plan = '''An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + ++ random_string.my_string + id: + length: "11" + lower: "true" + min_lower: "0" + min_numeric: "0" + min_special: "0" + min_upper: "0" + number: "true" + result: + special: "true" + upper: "true" +Plan: 1 to add, 0 to change, 0 to destroy. +''' + expected = 'Plan: 1 to add, 0 to change, 0 to destroy.' + + assert create_summary(plan) == expected + + +def test_summary_plan_12(): + plan = '''An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy. +''' + expected = 'Plan: 1 to add, 0 to change, 0 to destroy.' + + assert create_summary(plan) == expected + + +def test_summary_plan_14(): + plan = '''An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + s = "string" +''' + expected = 'Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs.' + + assert create_summary(plan) == expected + + +def test_summary_error_11(): + plan = """ +Error: random_string.my_string: length: cannot parse '' as int: strconv.ParseInt: parsing "ten": invalid syntax + +""" + expected = "Error: random_string.my_string: length: cannot parse '' as int: strconv.ParseInt: parsing \"ten\": invalid syntax" + + assert create_summary(plan) == expected + + +def test_summary_error_12(): + plan = """ +Error: Incorrect attribute value type + + on main.tf line 2, in resource "random_string" "my_string": + 2: length = "ten" + +Inappropriate value for attribute "length": a number is required. +""" + + expected = "Error: Incorrect attribute value type" + assert create_summary(plan) == expected + + +def test_summary_no_change_11(): + plan = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. +""" + + expected = "No changes. Infrastructure is up-to-date." + assert create_summary(plan) == expected + + +def test_summary_no_change_14(): + plan = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. +""" + + expected = "No changes. Infrastructure is up-to-date." + assert create_summary(plan) == expected + + +def test_summary_output_only_change_14(): + plan = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + +Terraform will perform the following actions: + +Plan: 0 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + hello = "world" + +""" + + expected = "Plan: 0 to add, 0 to change, 0 to destroy. Changes to Outputs." + assert create_summary(plan) == expected + + +def test_summary_unknown(): + plan = """ +This is not anything like terraform output we know. We don't want to generate a summary for this. +""" + assert create_summary(plan) is None diff --git a/tests/infra/http-auth.py b/tests/infra/http-auth.py new file mode 100644 index 00000000..85b65c5d --- /dev/null +++ b/tests/infra/http-auth.py @@ -0,0 +1,34 @@ +import base64 +import os + +unauth = { + 'statusCode': 401, + 'headers': { + 'www-authenticate': 'Basic realm="terraform-module"' + }, + 'body': 'Please authenticate' +} + + +def lambda_handler(event, context): + print(event) + + if 'authorization' not in event['headers']: + return unauth + + encoded = event['headers']['authorization'][len('Basic '):] + decoded = base64.b64decode(encoded) + separated = decoded.decode().split(':', maxsplit=1) + username = separated[0] + password = separated[1] + + if username != os.environ['USERNAME'] or password != os.environ['PASSWORD']: + return unauth + + return { + 'statusCode': 200, + 'body': '', + 'headers': { + 'content-type': 'text/html' + } + } diff --git a/tests/new-workspace/remote_12/main.tf b/tests/new-workspace/remote_12/main.tf deleted file mode 100644 index 602e9a26..00000000 --- a/tests/new-workspace/remote_12/main.tf +++ /dev/null @@ -1,21 +0,0 @@ -resource "random_string" "my_string" { - length = 5 -} - -variable "my_string" { - type = string -} - -output "my_string" { - value = var.my_string -} - -terraform { - backend "s3" { - bucket = "terraform-github-actions" - key = "terraform-new-workspace" - region = "eu-west-2" - } - - required_version = "~> 0.12.0" -} \ No newline at end of file diff --git a/tests/new-workspace/remote_13/main.tf b/tests/new-workspace/remote_13/main.tf deleted file mode 100644 index df825df8..00000000 --- a/tests/new-workspace/remote_13/main.tf +++ /dev/null @@ -1,21 +0,0 @@ -resource "random_string" "my_string" { - length = 5 -} - -variable "my_string" { - type = string -} - -output "my_string" { - value = var.my_string -} - -terraform { - backend "s3" { - bucket = "terraform-github-actions" - key = "terraform-new-workspace-13" - region = "eu-west-2" - } - - required_version = "~> 0.13.0" -} \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..1282130b --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,7 @@ +requests +pytest +python-hcl2 +canonicaljson +types-requests +mypy +flake8 diff --git a/tests/terraform-cloud/main.tf b/tests/terraform-cloud/main.tf deleted file mode 100644 index b11cfa57..00000000 --- a/tests/terraform-cloud/main.tf +++ /dev/null @@ -1,14 +0,0 @@ -terraform { - backend "remote" { - organization = "flooktech" - - workspaces { - prefix = "github-actions-" - } - } - required_version = "~> 0.13.0" -} - -resource "random_id" "the_id" { - byte_length = 5 -} diff --git a/tests/terraform_version/test_asdf.py b/tests/terraform_version/test_asdf.py new file mode 100644 index 00000000..3bd91256 --- /dev/null +++ b/tests/terraform_version/test_asdf.py @@ -0,0 +1,46 @@ +from __future__ import annotations +from terraform.versions import Version +from terraform_version.asdf import parse_asdf + + +def test_parse_asdf(): + versions = [ + Version('0.13.6'), + Version('1.1.8'), + Version('1.1.9'), + Version('1.1.7'), + Version('1.1.0-alpha20210811'), + Version('1.2.0-alpha20225555') + ] + + assert parse_asdf('terraform 0.13.6', versions) == Version('0.13.6') + assert parse_asdf(''' + # comment + terraform 0.15.6 #basdasd + + ''', versions) == Version('0.15.6') + + assert parse_asdf('terraform 1.1.1-cool', versions) == Version('1.1.1-cool') + + try: + parse_asdf('', versions) + except Exception: + pass + else: + assert False + + try: + parse_asdf('blahblah', versions) + except Exception: + pass + else: + assert False + + try: + parse_asdf('terraform blasdasf', versions) + except Exception: + pass + else: + assert False + + assert parse_asdf('terraform latest', versions) == Version('1.1.9') diff --git a/tests/terraform_version/test_latest.py b/tests/terraform_version/test_latest.py new file mode 100644 index 00000000..6028e88c --- /dev/null +++ b/tests/terraform_version/test_latest.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from terraform.versions import Version, earliest_version, latest_version, earliest_non_prerelease_version, latest_non_prerelease_version + + +def test_latest(): + versions = [ + Version('0.13.6-alpha-23'), + Version('0.13.6'), + Version('1.1.8'), + Version('1.1.9'), + Version('1.1.7'), + Version('1.1.0-alpha20210811'), + Version('1.2.0-alpha20225555') + ] + + assert earliest_version(versions) == Version('0.13.6-alpha-23') + assert latest_version(versions) == Version('1.2.0-alpha20225555') + assert earliest_non_prerelease_version(versions) == Version('0.13.6') + assert latest_non_prerelease_version(versions) == Version('1.1.9') diff --git a/tests/terraform_version/test_local_state.py b/tests/terraform_version/test_local_state.py new file mode 100644 index 00000000..5e7f344f --- /dev/null +++ b/tests/terraform_version/test_local_state.py @@ -0,0 +1,151 @@ +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from terraform.download import get_executable +from terraform.versions import Version +from terraform_version.local_state import read_local_state + +terraform_versions = [ + '1.1.2', + '1.1.1', + '1.1.0', + '1.0.11', + '1.0.10', + '1.0.9', + '1.0.8', + '1.0.7', + '1.0.6', + '1.0.5', + '1.0.4', + '1.0.3', + '1.0.2', + '1.0.1', + '1.0.0', + '0.15.5', + '0.15.4', + '0.15.3', + '0.15.2', + '0.15.1', + '0.15.0', + '0.14.11', + '0.14.10', + '0.14.9', + '0.14.8', + '0.14.7', + '0.14.6', + '0.14.5', + '0.14.4', + '0.14.3', + '0.14.2', + '0.14.1', + '0.14.0', + '0.13.7', + '0.13.6', + '0.13.5', + '0.13.4', + '0.13.3', + '0.13.2', + '0.13.1', + '0.13.0', + '0.12.31', + '0.12.30', + '0.12.29', + '0.12.28', + '0.12.27', + '0.12.26', + '0.12.25', + '0.12.24', + '0.12.23', + '0.12.21', + '0.12.20', + '0.12.19', + '0.12.18', + '0.12.17', + '0.12.16', + '0.12.15', + '0.12.14', + '0.12.13', + '0.12.12', + '0.12.11', + '0.12.10', + '0.12.9', + '0.12.8', + '0.12.7', + '0.12.6', + '0.12.5', + '0.12.4', + '0.12.3', + '0.12.2', + '0.12.1', + '0.12.0', + '0.11.15', + '0.11.14', + '0.11.13', + '0.11.12', + '0.11.11', + '0.11.10', + '0.11.9', + '0.11.8', + '0.11.7', + '0.11.6', + '0.11.5', + '0.11.4', + '0.11.3', + '0.11.2', + '0.11.1', + '0.11.0', + '0.10.8', + '0.10.7', + '0.10.6', + '0.10.5', + '0.10.4', + '0.10.3', + '0.10.2', + '0.10.1', + '0.10.0' +] + + +@pytest.fixture(scope='module', params=["0.11.8", "1.1.2"]) +def local_state_version(request): + terraform_version = Version(request.param) + terraform_path = get_executable(Version(request.param)) + + module_dir = Path(os.getcwd(), '.local_state_version', str(terraform_version)) + os.makedirs(module_dir, exist_ok=True) + + with open(os.path.join(module_dir, 'main.tf'), 'w') as f: + f.write(''' + output "hello" { value = "hello" } + ''') + + # Here we go + result = subprocess.run( + [terraform_path, 'init'], + env={'TF_INPUT': 'false'}, + capture_output=True, + cwd=module_dir + ) + assert result.returncode == 0 + result = subprocess.run( + [terraform_path, 'apply', '-auto-approve'], + env={'TF_INPUT': 'false'}, + capture_output=True, + cwd=module_dir + ) + assert result.returncode == 0 + + shutil.rmtree(os.path.join(module_dir, '.terraform'), ignore_errors=True) + + yield module_dir, terraform_version + + shutil.rmtree(module_dir, ignore_errors=True) + + +def test_state(local_state_version): + module_dir, terraform_version = local_state_version + assert read_local_state(module_dir) == terraform_version diff --git a/tests/terraform_version/test_remote_state_s3.py b/tests/terraform_version/test_remote_state_s3.py new file mode 100644 index 00000000..216a5814 --- /dev/null +++ b/tests/terraform_version/test_remote_state_s3.py @@ -0,0 +1,194 @@ +import os +import shutil +import subprocess + +import hcl2 +import pytest + +from terraform.download import download_version, get_executable +from terraform.module import load_module +from terraform.versions import Version, apply_constraints +from terraform_version.remote_state import try_guess_state_version, get_backend_constraints + +terraform_versions = [ + '1.1.3', + '1.1.2', + '1.1.1', + '1.1.0', + '1.0.11', + '1.0.10', + '1.0.9', + '1.0.8', + '1.0.7', + '1.0.6', + '1.0.5', + '1.0.4', + '1.0.3', + '1.0.2', + '1.0.1', + '1.0.0', + '0.15.5', + '0.15.4', + '0.15.3', + '0.15.2', + '0.15.1', + '0.15.0', + '0.14.11', + '0.14.10', + '0.14.9', + '0.14.8', + '0.14.7', + '0.14.6', + '0.14.5', + '0.14.4', + '0.14.3', + '0.14.2', + '0.14.1', + '0.14.0', + '0.13.7', + '0.13.6', + '0.13.5', + '0.13.4', + '0.13.3', + '0.13.2', + '0.13.1', + '0.13.0', + '0.12.31', + '0.12.30', + '0.12.29', + '0.12.28', + '0.12.27', + '0.12.26', + '0.12.25', + '0.12.24', + '0.12.23', + '0.12.21', + '0.12.20', + '0.12.19', + '0.12.18', + '0.12.17', + '0.12.16', + '0.12.15', + '0.12.14', + '0.12.13', + '0.12.12', + '0.12.11', + '0.12.10', + '0.12.9', + '0.12.8', + '0.12.7', + '0.12.6', + '0.12.5', + '0.12.4', + '0.12.3', + '0.12.2', + '0.12.1', + '0.12.0', + '0.11.15', + '0.11.14', + '0.11.13', + '0.11.12', + '0.11.11', + '0.11.10', + '0.11.9', + '0.11.8', + '0.11.7', + '0.11.6', + '0.11.5', + '0.11.4', + '0.11.3', + '0.11.2', + '0.11.1', + '0.11.0', + '0.10.8', + '0.10.7', + '0.10.6', + '0.10.5', + '0.10.4', + '0.10.3', + '0.10.2', + '0.10.1', + '0.10.0', + "0.9.11", + "0.9.10", + "0.9.9", + "0.9.8", + "0.9.7", +] + +@pytest.fixture(scope='module', params=["0.9.7", "0.11.8", "1.1.2"]) +def state_version(request): + terraform_version = Version(request.param) + terraform_path = get_executable(terraform_version) + + module_dir = os.path.join(os.getcwd(), '.terraform-state', str(terraform_version)) + os.makedirs(module_dir, exist_ok=True) + + with open(os.path.join(module_dir, 'main.tf'), 'w') as f: + backend_tf = ''' +terraform { + backend "s3" { + bucket = "terraform-github-actions" + key = "test_remote_state_s3_''' + str(terraform_version) + '''" + region = "eu-west-2" + dynamodb_table = "terraform-github-actions" + } +} + ''' + + f.write(backend_tf + ''' + +output "hello" { + value = "hello" +} + ''') + + # Here we go + result = subprocess.run( + [terraform_path, 'init'], + env=os.environ | {'TF_INPUT': 'false'}, + capture_output=True, + cwd=module_dir + ) + print(f'{result.args=}') + print(f'{result.returncode=}') + print(f'{result.stdout.decode()=}') + print(f'{result.stderr.decode()=}') + assert result.returncode == 0 + + result = subprocess.run( + [terraform_path, 'apply'] + (['-auto-approve'] if terraform_version >= Version('0.10.0') else []), + env=os.environ | {'TF_INPUT': 'false'}, + capture_output=True, + cwd=module_dir + ) + print(f'{result.args=}') + print(f'{result.returncode=}') + print(f'{result.stdout.decode()=}') + print(f'{result.stderr.decode()=}') + assert result.returncode == 0 + + shutil.rmtree(os.path.join(module_dir, '.terraform'), ignore_errors=True) + + yield terraform_version, backend_tf + + shutil.rmtree(module_dir, ignore_errors=True) + +def test_state(state_version): + + terraform_version, backend_tf = state_version + + module = hcl2.loads(backend_tf) + + assert try_guess_state_version( + { + 'INPUT_BACKEND_CONFIG': '', + 'INPUT_BACKEND_CONFIG_FILE': '', + 'INPUT_WORKSPACE': 'default' + }, + module, + versions=apply_constraints( + sorted(Version(v) for v in terraform_versions), + get_backend_constraints(module, {}) + ) + ) == terraform_version diff --git a/tests/terraform_version/test_state.py b/tests/terraform_version/test_state.py new file mode 100644 index 00000000..a745d920 --- /dev/null +++ b/tests/terraform_version/test_state.py @@ -0,0 +1,82 @@ +import hcl2 + +from terraform.versions import Constraint +from terraform_version.remote_state import dump_backend_hcl, get_backend_constraints + + +def test_simple_backend(): + + expected_backend = hcl2.loads(''' + terraform { + backend "s3" { + bucket = "terraform-github-actions" + key = "blah" + region = "eu-west-2" + } + } +''') + + assert expected_backend == hcl2.loads(dump_backend_hcl(expected_backend)) + +def test_no_backend(): + expected_backend = hcl2.loads(''' + terraform { + required_version = "1.0.0" + } + ''') + + assert dump_backend_hcl(expected_backend).strip() == '' + +def test_oss_assume_role(): + expected_backend = hcl2.loads(''' + terraform { + backend "oss" { + access_key = "sausage" + assume_role { + role_arn = "asdasd" + session_name = "hello" + } + } + } + ''') + + assert expected_backend == hcl2.loads(dump_backend_hcl(expected_backend)) + +def test_backend_constraints(): + + module = hcl2.loads(''' + terraform { + backend "oss" { + access_key = "sausage" + mystery = true + assume_role { + role_arn = "asdasd" + session_name = "hello" + } + } + } + ''') + + assert get_backend_constraints(module, {}) == [Constraint('>=0.12.2'), Constraint('>=0.12.2'), Constraint('>=0.12.6')] + + module = hcl2.loads(''' + terraform { + backend "gcs" { + bucket = "sausage" + impersonate_service_account = true + region = "europe-west2" + unknown = "??" + path = "hello" + } + } + ''') + + assert get_backend_constraints(module, {}) == [ + Constraint('>=0.9.0'), + Constraint('>=0.9.0'), + Constraint('>=0.14.0'), + Constraint('>=0.11.0'), + Constraint('<=0.15.3'), + Constraint('>=0.9.0'), + Constraint('<=0.14.11') + ] diff --git a/tests/terraform_version/test_terraform_version.py b/tests/terraform_version/test_terraform_version.py new file mode 100644 index 00000000..d07b97ee --- /dev/null +++ b/tests/terraform_version/test_terraform_version.py @@ -0,0 +1,203 @@ +from terraform.versions import Version, Constraint +from terraform.exec import init_args + +def test_init_args(): + assert init_args({ + 'INPUT_BACKEND_CONFIG_FILE': '', + 'INPUT_BACKEND_CONFIG': '', + 'INPUT_PATH': '.' + }) == [] + + assert init_args({ + 'INPUT_BACKEND_CONFIG_FILE': 'tests/hello/terraform.backendconfig', + 'INPUT_BACKEND_CONFIG': '', + 'INPUT_PATH': '.' + }) == ['-backend-config=tests/hello/terraform.backendconfig'] + + assert init_args({ + 'INPUT_BACKEND_CONFIG_FILE': 'tests/hello/terraform.backendconfig', + 'INPUT_BACKEND_CONFIG': '', + 'INPUT_PATH': 'tests' + }) == ['-backend-config=hello/terraform.backendconfig'] + + assert init_args({ + 'INPUT_BACKEND_CONFIG_FILE': 'tests/terraform.backendconfig', + 'INPUT_BACKEND_CONFIG': '', + 'INPUT_PATH': 'tests/hello' + }) == ['-backend-config=../terraform.backendconfig'] + + assert init_args({ + 'INPUT_BACKEND_CONFIG_FILE': ''' + tests/terraform.backendconfig + env/prod/terraform.backendconfig,env/common.backendconfig + ''', + 'INPUT_BACKEND_CONFIG': '', + 'INPUT_PATH': '.' + }) == ['-backend-config=tests/terraform.backendconfig', '-backend-config=env/prod/terraform.backendconfig', '-backend-config=env/common.backendconfig'] + + assert init_args({ + 'INPUT_BACKEND_CONFIG_FILE': '', + 'INPUT_BACKEND_CONFIG': 'test=hello', + 'INPUT_PATH': '.' + }) == ['-backend-config=test=hello'] + + assert init_args({ + 'INPUT_BACKEND_CONFIG_FILE': '', + 'INPUT_BACKEND_CONFIG': ''' + + "test=hello" + + foo=bar,xyz=abc + + ''', + 'INPUT_PATH': '.' + }) == ['-backend-config="test=hello"', '-backend-config=foo=bar', '-backend-config=xyz=abc'] + +def test_version(): + v0_1_1 = Version('0.1.1') + assert v0_1_1.major == 0 and v0_1_1.minor == 1 and v0_1_1.patch == 1 and v0_1_1.pre_release == '' + assert str(v0_1_1) == '0.1.1' + + v1_0_11 = Version('1.0.11') + assert v1_0_11.major == 1 and v1_0_11.minor == 0 and v1_0_11.patch == 11 and v1_0_11.pre_release == '' + assert str(v1_0_11) == '1.0.11' + + v0_15_0_rc2 = Version('0.15.0-rc2') + assert v0_15_0_rc2.major == 0 and v0_15_0_rc2.minor == 15 and v0_15_0_rc2.patch == 0 and v0_15_0_rc2.pre_release == 'rc2' + assert str(v0_15_0_rc2) == '0.15.0-rc2' + + v0_15_0 = Version('0.15.0') + assert v0_15_0.major == 0 and v0_15_0.minor == 15 and v0_15_0.patch == 0 and v0_15_0.pre_release == '' + assert str(v0_15_0) == '0.15.0' + + assert v0_1_1 == v0_1_1 + assert v1_0_11 != v0_1_1 + assert v0_15_0_rc2 < v0_15_0 + assert v1_0_11 > v0_15_0 > v0_1_1 + +def test_constraint(): + constraint = Constraint('0.12.4-hello') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == 'hello' and constraint.operator == '=' + assert str(constraint) == '=0.12.4-hello' + assert constraint.is_allowed(Version('0.12.4-hello')) + assert not constraint.is_allowed(Version('0.12.4')) + + constraint = Constraint('0.12.4') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '=' + assert str(constraint) == '=0.12.4' + + constraint = Constraint(' = 0 .1 2. 4-hello') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == 'hello' and constraint.operator == '=' + assert str(constraint) == '=0.12.4-hello' + + constraint = Constraint(' = 0 .1 2. 4') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '=' + assert str(constraint) == '=0.12.4' + + constraint = Constraint(' >= 0 .1 2. 4') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '>=' + assert str(constraint) == '>=0.12.4' + assert constraint.is_allowed(Version('0.12.4')) + assert constraint.is_allowed(Version('0.12.8')) + assert constraint.is_allowed(Version('0.13.0')) + assert constraint.is_allowed(Version('1.1.1')) + assert not constraint.is_allowed(Version('0.12.3')) + assert not constraint.is_allowed(Version('0.10.0')) + + constraint = Constraint(' > 0 .1 2. 4') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '>' + assert str(constraint) == '>0.12.4' + assert not constraint.is_allowed(Version('0.12.4')) + assert constraint.is_allowed(Version('0.12.8')) + assert constraint.is_allowed(Version('0.13.0')) + assert constraint.is_allowed(Version('1.1.1')) + assert not constraint.is_allowed(Version('0.12.3')) + assert not constraint.is_allowed(Version('0.10.0')) + + constraint = Constraint(' < 0 .1 2. 4') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '<' + assert str(constraint) == '<0.12.4' + assert not constraint.is_allowed(Version('0.12.4')) + assert not constraint.is_allowed(Version('0.12.8')) + assert not constraint.is_allowed(Version('0.13.0')) + assert not constraint.is_allowed(Version('1.1.1')) + assert constraint.is_allowed(Version('0.12.3')) + assert constraint.is_allowed(Version('0.10.0')) + + constraint = Constraint(' <= 0 .1 2. 4') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '<=' + assert str(constraint) == '<=0.12.4' + assert constraint.is_allowed(Version('0.12.4')) + assert not constraint.is_allowed(Version('0.12.8')) + assert not constraint.is_allowed(Version('0.13.0')) + assert not constraint.is_allowed(Version('1.1.1')) + assert constraint.is_allowed(Version('0.12.3')) + assert constraint.is_allowed(Version('0.10.0')) + + constraint = Constraint(' != 0 .1 2. 4') + assert constraint.major == 0 and constraint.minor == 12 and constraint.patch == 4 and constraint.pre_release == '' and constraint.operator == '!=' + assert str(constraint) == '!=0.12.4' + assert not constraint.is_allowed(Version('0.12.4')) + assert constraint.is_allowed(Version('0.12.8')) + assert constraint.is_allowed(Version('0.13.0')) + assert constraint.is_allowed(Version('1.1.1')) + assert constraint.is_allowed(Version('0.12.3')) + assert constraint.is_allowed(Version('0.10.0')) + + constraint = Constraint('1') + assert ( + constraint.major == 1 + and constraint.minor is None + and constraint.patch is None + and constraint.pre_release == '' + and constraint.operator == '=' + ) + + assert str(constraint) == '=1' + assert constraint.is_allowed(Version('1.0.0')) + assert not constraint.is_allowed(Version('1.0.1')) + assert not constraint.is_allowed(Version('0.0.9')) + assert not constraint.is_allowed(Version('1.0.0-wooo')) + + constraint = Constraint('1.2') + assert ( + constraint.major == 1 + and constraint.minor == 2 + and constraint.patch is None + and constraint.pre_release == '' + and constraint.operator == '=' + ) + + assert str(constraint) == '=1.2' + + constraint = Constraint('~>1.2.3') + assert constraint.major == 1 and constraint.minor == 2 and constraint.patch == 3 and constraint.pre_release == '' and constraint.operator == '~>' + assert str(constraint) == '~>1.2.3' + + constraint = Constraint('~>1.2') + assert ( + constraint.major == 1 + and constraint.minor == 2 + and constraint.patch is None + and constraint.pre_release == '' + and constraint.operator == '~>' + ) + + assert str(constraint) == '~>1.2' + + assert Constraint('0.12.0') < Constraint('0.12.1') + assert Constraint('0.12.0') == Constraint('0.12.0') + assert Constraint('0.12.0') != Constraint('0.15.0') + + test_ordering = [ + Constraint('0.11.0'), + Constraint('<0.12.0'), + Constraint('<=0.12.0'), + Constraint('0.12.0'), + Constraint('~>0.12.0'), + Constraint('>=0.12.0'), + Constraint('>0.12.0'), + Constraint('0.12.5'), + Constraint('0.13.0'), + ] + assert test_ordering == sorted(test_ordering) diff --git a/tests/terraform_version/test_tfc.py b/tests/terraform_version/test_tfc.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/terraform_version/test_tfenv.py b/tests/terraform_version/test_tfenv.py new file mode 100644 index 00000000..30cb5ccf --- /dev/null +++ b/tests/terraform_version/test_tfenv.py @@ -0,0 +1,46 @@ +from terraform.versions import Version +from terraform_version.tfenv import parse_tfenv + +def test_parse_tfenv(): + versions = [ + Version('0.13.6'), + Version('1.1.8'), + Version('1.1.9'), + Version('1.1.7'), + Version('1.1.0-alpha20210811'), + Version('1.2.0-alpha20225555') + ] + + assert parse_tfenv('0.13.6', versions) == Version('0.13.6') + assert parse_tfenv(''' + + 0.15.6 + + ''', versions) == Version('0.15.6') + + assert parse_tfenv('1.1.1-cool', versions) == Version('1.1.1-cool') + + try: + parse_tfenv('', versions) + except ValueError: + pass + else: + assert False + + try: + parse_tfenv('blahblah', versions) + except ValueError: + pass + else: + assert False + + assert parse_tfenv('latest', versions) == Version('1.1.9') + assert parse_tfenv('latest:^1.1', versions) >= Version('1.1.8') + assert parse_tfenv('latest:1.8', versions) >= Version('1.1.0-alpha20210811') + + try: + parse_tfenv('latest:^1.8', versions) + except Exception: + pass + else: + assert False diff --git a/tests/terraform_version/test_tfswitch.py b/tests/terraform_version/test_tfswitch.py new file mode 100644 index 00000000..80c66ccc --- /dev/null +++ b/tests/terraform_version/test_tfswitch.py @@ -0,0 +1,27 @@ +from terraform.versions import Version +from terraform_version.tfswitch import parse_tfswitch + + +def test_parse_tfswitch(): + assert parse_tfswitch('0.13.6') == Version('0.13.6') + assert parse_tfswitch(''' + + 0.15.6 + + ''') == Version('0.15.6') + + assert parse_tfswitch('1.1.1-cool') == Version('1.1.1-cool') + + try: + parse_tfswitch('') + except ValueError: + pass + else: + assert False + + try: + parse_tfswitch('blahblah') + except ValueError: + pass + else: + assert False diff --git a/tests/test_cloud_state.py b/tests/test_cloud_state.py new file mode 100644 index 00000000..8514a843 --- /dev/null +++ b/tests/test_cloud_state.py @@ -0,0 +1,45 @@ +from terraform_cloud_state.__main__ import get_run_id, get_cloud_json_plan + + +def test_get_run_url(): + plan = """ +Running plan in the remote backend. Output will stream here. Pressing Ctrl-C +will stop streaming the logs, but will not stop the plan running remotely. + +Preparing the remote plan... + +To view this run in a browser, visit: +https://app.terraform.io/app/flooktech/github-actions-1-1temp/runs/run-6m9eAyLdeDSrPYqz + +Waiting for the plan to start... + +Terraform v1.1.6 +on linux_amd64 +Initializing plugins and modules... + +Terraform used the selected providers to generate the following execution +plan. Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_id.the_id will be created + + resource "random_id" "the_id" { + + b64_std = (known after apply) + + b64_url = (known after apply) + + byte_length = 5 + + dec = (known after apply) + + hex = (known after apply) + + id = (known after apply) + } + +Plan: 1 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + default = "default" + + from_tfvars = "default" + + from_variables = "default" + +""" + + assert get_run_id(plan) == 'run-6m9eAyLdeDSrPYqz' diff --git a/tests/test_compact_plan.py b/tests/test_compact_plan.py new file mode 100644 index 00000000..6c3154d5 --- /dev/null +++ b/tests/test_compact_plan.py @@ -0,0 +1,665 @@ +from compact_plan import compact_plan + + +def test_plan_11(): + input = """Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + ++ random_string.my_string + id: + length: "11" + lower: "true" + min_lower: "0" + min_numeric: "0" + min_special: "0" + min_upper: "0" + number: "true" + result: + special: "true" + upper: "true" +Plan: 1 to add, 0 to change, 0 to destroy. +""" + + expected_output = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + ++ random_string.my_string + id: + length: "11" + lower: "true" + min_lower: "0" + min_numeric: "0" + min_special: "0" + min_upper: "0" + number: "true" + result: + special: "true" + upper: "true" +Plan: 1 to add, 0 to change, 0 to destroy.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_12(): + input = """Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy. +""" + + expected_output = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_14(): + input = """ +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + s = "string" +""" + + expected_output = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + s = "string\"""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_15(): + input = """ + +Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + s = "string" +""" + + expected_output = """Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + s = "string\"""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_refresh_on_changes_11(): + input = """ +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +random_string.my_string: Refreshing state... (ID: Zl$lcns(v>) + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. +""" + + expected_output = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_refresh_no_changes_14(): + input = """ +random_string.my_string: Refreshing state... [id=&)+#Z$b@=b] + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. +""" + + expected_output = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_refresh_no_changes_15(): + input = """ +random_string.my_string: Refreshing state... [id=&)+#Z$b@=b] + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your configuration and the remote system(s). As a result, there are no actions to take. +""" + + expected_output = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your configuration and the remote system(s). As a result, there are no actions to take.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_refresh_no_changes_1(): + input = """ +random_string.my_string: Refreshing state... [id=&)+#Z$b@=b] + +No changes. Your infrastructure matches the configuration. + +Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. +""" + + expected_output = """No changes. Your infrastructure matches the configuration. + +Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + + +def test_plan_no_resource_output_only_11(): + input = """ +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. + """ + + expected_output = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. + """ + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_no_resource_output_only_14(): + input = """ +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + +Terraform will perform the following actions: + +Plan: 0 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + t = "hello" + """ + + expected_output = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + +Terraform will perform the following actions: + +Plan: 0 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + t = "hello" + """ + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_no_resource_output_only_15_4(): + input = """ +Changes to Outputs: + + t = "hello" + +You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure. + """ + + expected_output = """Changes to Outputs: + + t = "hello" + +You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure. + """ + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_refresh_changes_11(): + input = """ +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +random_string.my_string: Refreshing state... (ID: <2jMa%O-E$) + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: +-/+ destroy and then create replacement + +Terraform will perform the following actions: + +-/+ random_string.my_string (new resource required) + id: "<2jMa%O-E$" => (forces new resource) + length: "10" => "5" (forces new resource) + lower: "true" => "true" + min_lower: "0" => "0" + min_numeric: "0" => "0" + min_special: "0" => "0" + min_upper: "0" => "0" + number: "true" => "true" + result: "<2jMa%O-E$" => + special: "true" => "true" + upper: "true" => "true" +Plan: 1 to add, 0 to change, 1 to destroy. + """ + + expected_output = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: +-/+ destroy and then create replacement + +Terraform will perform the following actions: + +-/+ random_string.my_string (new resource required) + id: "<2jMa%O-E$" => (forces new resource) + length: "10" => "5" (forces new resource) + lower: "true" => "true" + min_lower: "0" => "0" + min_numeric: "0" => "0" + min_special: "0" => "0" + min_upper: "0" => "0" + number: "true" => "true" + result: "<2jMa%O-E$" => + special: "true" => "true" + upper: "true" => "true" +Plan: 1 to add, 0 to change, 1 to destroy. + """ + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_refresh_changes_14(): + input = """ +random_string.my_string: Refreshing state... [id=Iyh3jLKc] + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # random_string.my_string must be replaced +-/+ resource "random_string" "my_string" { + ~ id = "Iyh3jLKc" -> (known after apply) + ~ length = 8 -> 4 # forces replacement + ~ result = "Iyh3jLKc" -> (known after apply) + # (8 unchanged attributes hidden) + } + +Plan: 1 to add, 0 to change, 1 to destroy. + """ + + expected_output = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # random_string.my_string must be replaced +-/+ resource "random_string" "my_string" { + ~ id = "Iyh3jLKc" -> (known after apply) + ~ length = 8 -> 4 # forces replacement + ~ result = "Iyh3jLKc" -> (known after apply) + # (8 unchanged attributes hidden) + } + +Plan: 1 to add, 0 to change, 1 to destroy. + """ + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_plan_refresh_changes_15(): + input = """ +random_string.my_string: Refreshing state... [id=Iyh3jLKc] + +Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # random_string.my_string must be replaced +-/+ resource "random_string" "my_string" { + ~ id = "Iyh3jLKc" -> (known after apply) + ~ length = 8 -> 4 # forces replacement + ~ result = "Iyh3jLKc" -> (known after apply) + # (8 unchanged attributes hidden) + } + +Plan: 1 to add, 0 to change, 1 to destroy. + """ + + expected_output = """Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # random_string.my_string must be replaced +-/+ resource "random_string" "my_string" { + ~ id = "Iyh3jLKc" -> (known after apply) + ~ length = 8 -> 4 # forces replacement + ~ result = "Iyh3jLKc" -> (known after apply) + # (8 unchanged attributes hidden) + } + +Plan: 1 to add, 0 to change, 1 to destroy. + """ + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_error_11(): + input = """ +Error: random_string.my_string: length: cannot parse '' as int: strconv.ParseInt: parsing "ten": invalid syntax + +""" + + expected_output = """Error: random_string.my_string: length: cannot parse '' as int: strconv.ParseInt: parsing "ten": invalid syntax +""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_error_12(): + input = """ +Error: Incorrect attribute value type + + on main.tf line 2, in resource "random_string" "my_string": + 2: length = "ten" + +Inappropriate value for attribute "length": a number is required. +""" + + expected_output = """Error: Incorrect attribute value type + + on main.tf line 2, in resource "random_string" "my_string": + 2: length = "ten" + +Inappropriate value for attribute "length": a number is required.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_no_change_11(): + input = """Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. +""" + + expected_output = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_no_change_14(): + input = """ +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. +""" + + expected_output = """No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_no_output(): + input = """ +This is not anything like terraform output we know. We want this to be output unchanged. +This should protect against the output changing again. +""" + + expected_output = """ +This is not anything like terraform output we know. We want this to be output unchanged. +This should protect against the output changing again.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output + +def test_state_lock_12(): + input = """Acquiring state lock. This may take a few moments... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +Acquiring state lock. This may take a few moments... +Releasing state lock. This may take a few moments... + +------------------------------------------------------------------------ +Acquiring state lock. This may take a few moments... + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create +Acquiring state lock. This may take a few moments... + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true +Acquiring state lock. This may take a few moments... + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true +Releasing state lock. This may take a few moments... + } + +Plan: 1 to add, 0 to change, 0 to destroy. +Releasing state lock. This may take a few moments... +Releasing state lock. This may take a few moments... +""" + + expected_output = """An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # random_string.my_string will be created + + resource "random_string" "my_string" { + + id = (known after apply) + + length = 11 + + lower = true + + min_lower = 0 + + min_numeric = 0 + + min_special = 0 + + min_upper = 0 + + number = true + + result = (known after apply) + + special = true + + upper = true + } + +Plan: 1 to add, 0 to change, 0 to destroy.""" + + output = '\n'.join(compact_plan(input.splitlines())) + assert output == expected_output diff --git a/tests/test_git_credential_actions.py b/tests/test_git_credential_actions.py new file mode 100644 index 00000000..385a369f --- /dev/null +++ b/tests/test_git_credential_actions.py @@ -0,0 +1,152 @@ +from http_credential_actions_helper import ( + read_credentials, + Credential, + read_attributes, + write_attributes, + split_path, + git_credential +) + + +def test_read_credentials(): + input = ''' + +nonsense +example.com=dflook:mypassword + github.com/dflook/terraform-github-actions.git=dflook-actions:anotherpassword + +github.com/dflook=dflook:secretpassword +github.com=graham:abcd + +almost/= user : pass + +''' + + expected_credentials = [ + Credential('example.com', [], 'dflook', 'mypassword'), + Credential('github.com', ['dflook', 'terraform-github-actions.git'], 'dflook-actions', 'anotherpassword'), + Credential('github.com', ['dflook'], 'dflook', 'secretpassword'), + Credential('github.com', [], 'graham', 'abcd'), + Credential('almost', [], 'user', 'pass') + ] + + actual_credentials = list(read_credentials(input)) + assert actual_credentials == expected_credentials + + assert [] == list(read_credentials('')) + +def test_read_attributes(): + input = ''' +protocol=https +host=example.com +path=abcd.git +username=bob +password=secr3t + ''' + + expected = { + 'protocol': 'https', + 'host': 'example.com', + 'path': 'abcd.git', + 'username': 'bob', + 'password': 'secr3t' + } + + actual = read_attributes(input) + assert actual == expected + + assert {} == read_attributes('') + +def test_write_attributes(): + input = { + 'protocol': 'https', + 'host': 'example.com', + 'path': 'abcd.git', + 'username': 'bob', + 'somethingelse': 'hello', + 'password': 'secr3t' + } + + expected = ''' +protocol=https +host=example.com +path=abcd.git +username=bob +somethingelse=hello +password=secr3t + '''.strip() + + actual = write_attributes(input) + assert actual == expected + + assert '' == write_attributes({}) + +def test_split_path(): + assert [] == split_path(None) + assert [] == split_path('/') + assert ['hello'] == split_path('/hello') + assert ['dflook', 'terraform-github-actions.git'] == split_path('/dflook/terraform-github-actions.git') + +def test_get(): + + credentials = [ + Credential('example.com', [], 'dflook', 'mypassword'), + Credential('github.com', ['dflook', 'terraform-github-actions.git'], 'dflook-actions', 'anotherpassword'), + Credential('github.com', ['dflook'], 'dflook-org', 'secretpassword'), + Credential('github.com', [], 'graham', 'abcd'), + Credential('almost', [], 'user', 'pass') + ] + + def merge(attributes, **kwargs): + return {**attributes, **kwargs} + + # No path, no username + attributes = dict(protocol='https', host='example.com') + assert git_credential('get', attributes, credentials) == merge(attributes, username='dflook', password='mypassword') + + # No path, required username match + attributes = dict(protocol='https', host='example.com', username='dflook') + assert git_credential('get', attributes, credentials) == merge(attributes, password='mypassword') + + # No path, required username no match + attributes = dict(protocol='https', host='example.com', username='sandra') + assert git_credential('get', attributes, credentials) == attributes + + # partial path, required username no match + attributes = dict(protocol='https', host='github.com', path='dflook', username='keith') + assert git_credential('get', attributes, credentials) == attributes + + # full path + attributes = dict(protocol='https', host='github.com', path='dflook/terraform-github-actions.git') + assert git_credential('get', attributes, credentials) == merge(attributes, username='dflook-actions', password='anotherpassword') + + # partial path multiple segments + attributes = dict(protocol='https', host='github.com', path='dflook/terraform-github-actions.git/additional-segment') + assert git_credential('get', attributes, credentials) == merge(attributes, username='dflook-actions', password='anotherpassword') + + # partial path single segment + attributes = dict(protocol='https', host='github.com', path='dflook') + assert git_credential('get', attributes, credentials) == merge(attributes, username='dflook-org', password='secretpassword') + + # no path match + attributes = dict(protocol='https', host='github.com', path='sausage') + assert git_credential('get', attributes, credentials) == merge(attributes, username='graham', password='abcd') + + # Cases we don't handle - return attributes unchanged + attributes = dict(protocol='https', host='example.com', username='dflook', password='mypassword') + assert git_credential('store', attributes, credentials) == attributes + + attributes = dict(protocol='https', host='example.com') + assert git_credential('erase', attributes, credentials) == attributes + + attributes = dict(protocol='https', host='example.com') + assert git_credential('nonsense', attributes, credentials) == attributes + + attributes = dict(protocol='git', host='example.com') + assert git_credential('get', attributes, credentials) == attributes + + attributes = dict(host='example.com') + assert git_credential('get', attributes, credentials) == attributes + + attributes = dict(protocol='http') + assert git_credential('get', attributes, credentials) == attributes diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 00000000..64647135 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,226 @@ +from convert_validate_report import convert_to_github + + +def test_valid(): + input = { + "valid": True, + "error_count": 0, + "warning_count": 0, + "diagnostics": [] + } + + output = list(convert_to_github(input, 'terraform')) + assert output == [] + + +def test_invalid(): + input = { + "valid": False, + "error_count": 2, + "warning_count": 0, + "diagnostics": [ + { + "severity": "error", + "summary": "Could not satisfy plugin requirements", + "detail": "\nPlugin reinitialization required. Please run \"terraform init\".\n\nPlugins are external binaries that Terraform uses to access and manipulate\nresources. The configuration provided requires plugins which can't be located,\ndon't satisfy the version constraints, or are otherwise incompatible.\n\nTerraform automatically discovers providers requirements from your\nconfiguration, including plugin-cache used in child modules. To see the\nrequirements and constraints from each module, run \"terraform plugin-cache\".\n" + }, + { + "severity": "error", + "summary": "providers.null: no suitable version installed\n version requirements: \"(any version)\"\n versions installed: none" + } + ] + } + + expected_output = [ + '::error ::Could not satisfy plugin requirements', + '::error ::providers.null: no suitable version installed' + ] + + output = list(convert_to_github(input, 'terraform')) + assert output == expected_output + + +def test_blah(): + input = { + "valid": False, + "error_count": 1, + "warning_count": 0, + "diagnostics": [ + { + "severity": "error", + "summary": "Duplicate resource \"null_resource\" configuration", + "detail": "A null_resource resource named \"hello\" was already declared at main.tf:1,1-33. Resource names must be unique per type in each module.", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 1, + "byte": 36 + }, + "end": { + "line": 2, + "column": 33, + "byte": 68 + } + } + } + ] + } + + expected_output = [ + '::error file=terraform/main.tf,line=2,col=1,endLine=2,endColumn=33::Duplicate resource "null_resource" configuration' + ] + + output = list(convert_to_github(input, 'terraform')) + assert output == expected_output + + +def test_invalid_paths(): + input = { + "valid": False, + "error_count": 2, + "warning_count": 0, + "diagnostics": [ + { + "severity": "error", + "summary": "Duplicate resource \"null_resource\" configuration", + "detail": "A null_resource resource named \"hello\" was already declared at main.tf:1,1-33. Resource names must be unique per type in each module.", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 1, + "byte": 36 + }, + "end": { + "line": 2, + "column": 33, + "byte": 68 + } + } + }, + { + "severity": "error", + "summary": "Duplicate resource \"null_resource\" configuration", + "detail": "A null_resource resource named \"goodbye\" was already declared at ../module/invalid.tf:1,1-33. Resource names must be unique per type in each module.", + "range": { + "filename": "../module/invalid.tf", + "start": { + "line": 2, + "column": 1, + "byte": 36 + }, + "end": { + "line": 5, + "column": 66, + "byte": 68 + } + } + } + ] + } + + expected_output = [ + '::error file=tests/validate/invalid/main.tf,line=2,col=1,endLine=2,endColumn=33::Duplicate resource "null_resource" configuration', + '::error file=tests/validate/module/invalid.tf,line=2,col=1,endLine=5,endColumn=66::Duplicate resource "null_resource" configuration' + ] + + output = list(convert_to_github(input, 'tests/validate/invalid')) + assert output == expected_output + + +def test_json_0_1(): + input = { + "format_version": "0.1", + "valid": False, + "error_count": 2, + "warning_count": 0, + "diagnostics": [ + { + "severity": "error", + "summary": "Duplicate resource \"null_resource\" configuration", + "detail": "A null_resource resource named \"hello\" was already declared at main.tf:1,1-33. Resource names must be unique per type in each module.", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 1, + "byte": 36 + }, + "end": { + "line": 2, + "column": 33, + "byte": 68 + } + }, + "snippet": { + "context": None, + "code": "resource \"null_resource\" \"hello\" {}", + "start_line": 2, + "highlight_start_offset": 0, + "highlight_end_offset": 32, + "values": [] + } + }, + { + "severity": "error", + "summary": "Duplicate resource \"null_resource\" configuration", + "detail": "A null_resource resource named \"goodbye\" was already declared at main.tf:5,1-33. Resource names must be unique per type in each module.", + "range": { + "filename": "main.tf", + "start": { + "line": 6, + "column": 1, + "byte": 110 + }, + "end": { + "line": 6, + "column": 33, + "byte": 142 + } + }, + "snippet": { + "context": None, + "code": "resource \"null_resource\" goodbye {}", + "start_line": 6, + "highlight_start_offset": 0, + "highlight_end_offset": 32, + "values": [] + } + }, + { + "severity": "error", + "summary": "Module not installed", + "detail": "This module is not yet installed. Run \"terraform init\" to install all modules required by this configuration.", + "range": { + "filename": "main.tf", + "start": { + "line": 11, + "column": 1, + "byte": 201 + }, + "end": { + "line": 11, + "column": 13, + "byte": 213 + } + }, + "snippet": { + "context": None, + "code": "module \"vpc\" {", + "start_line": 11, + "highlight_start_offset": 0, + "highlight_end_offset": 12, + "values": [] + } + } + ] + } + + expected_output = [ + '::error file=tests/validate/invalid/main.tf,line=2,col=1,endLine=2,endColumn=33::Duplicate resource "null_resource" configuration', + '::error file=tests/validate/invalid/main.tf,line=6,col=1,endLine=6,endColumn=33::Duplicate resource "null_resource" configuration' + ] + + output = list(convert_to_github(input, 'tests/validate/invalid')) + assert output == expected_output diff --git a/tests/version/test_version.py b/tests/test_version.py similarity index 62% rename from tests/version/test_version.py rename to tests/test_version.py index e41a7e54..42ae9c3f 100644 --- a/tests/version/test_version.py +++ b/tests/test_version.py @@ -1,4 +1,9 @@ -from convert_version import convert_version +import os + +from convert_version import convert_version, convert_version_from_json + +from terraform.cloud import get_workspaces, new_workspace, delete_workspace + def test_convert_version(): tf_version_output = 'Terraform v0.12.28' @@ -54,3 +59,25 @@ def test_convert_0_13_providers(): ] assert list(convert_version(tf_version_output)) == expected + +def test_convert_0_13_json_providers(): + tf_version_output = { + "terraform_version": "0.13.0", + "terraform_revision": "", + "provider_selections": { + "registry.terraform.io/hashicorp/random": "2.2.0", + "registry.terraform.io/terraform-providers/acme": "2.5.3" + }, + "terraform_outdated": True + } + + expected = [ + 'Terraform v0.13.0', + '::set-output name=terraform::0.13.0', + '+ provider registry.terraform.io/hashicorp/random v2.2.0', + '::set-output name=random::2.2.0', + '+ provider registry.terraform.io/terraform-providers/acme v2.5.3', + '::set-output name=acme::2.5.3' + ] + + assert list(convert_version_from_json(tf_version_output)) == expected diff --git a/tests/test_write_credentials.py b/tests/test_write_credentials.py new file mode 100644 index 00000000..4f1ef02d --- /dev/null +++ b/tests/test_write_credentials.py @@ -0,0 +1,49 @@ +from format_tf_credentials import format_credentials + + +def test_single_cred(): + input = """app.terraform.io=xxxxxx.atlasv1.zzzzzzzzzzzzz""" + + expected_output = """credentials "app.terraform.io" { + token = "xxxxxx.atlasv1.zzzzzzzzzzzzz" +} +""" + + output = ''.join(format_credentials(input)) + assert output == expected_output + +def test_multiple_creds(): + input = """ + + app.terraform.io=xxxxxx.atlasv1.zzzzzzzzzzzzz + +terraform.example.com=abcdefg + +""" + + expected_output = """credentials "app.terraform.io" { + token = "xxxxxx.atlasv1.zzzzzzzzzzzzz" +} +credentials "terraform.example.com" { + token = "abcdefg" +} +""" + + output = ''.join(format_credentials(input)) + assert output == expected_output + +def test_unrecognised_lines(): + input = """ + + app.terraform.io=xxxxxx.atlasv1.zzzzzzzzzzzzz + + This doesn't look anything like a credential + + """ + + try: + output = ''.join(format_credentials(input)) + except ValueError as e: + pass + else: + assert False, 'Should have raised an exception' diff --git a/tests/validate/test_validate.py b/tests/validate/test_validate.py deleted file mode 100644 index 8e1625b9..00000000 --- a/tests/validate/test_validate.py +++ /dev/null @@ -1,126 +0,0 @@ -from convert_validate_report import convert_to_github - -def test_valid(): - input = { - "valid": True, - "error_count": 0, - "warning_count": 0, - "diagnostics": [] - } - - output = list(convert_to_github(input, 'terraform')) - assert output == [] - -def test_invalid(): - input = { - "valid": False, - "error_count": 2, - "warning_count": 0, - "diagnostics": [ - { - "severity": "error", - "summary": "Could not satisfy plugin requirements", - "detail": "\nPlugin reinitialization required. Please run \"terraform init\".\n\nPlugins are external binaries that Terraform uses to access and manipulate\nresources. The configuration provided requires plugins which can't be located,\ndon't satisfy the version constraints, or are otherwise incompatible.\n\nTerraform automatically discovers providers requirements from your\nconfiguration, including plugin-cache used in child modules. To see the\nrequirements and constraints from each module, run \"terraform plugin-cache\".\n" - }, - { - "severity": "error", - "summary": "providers.null: no suitable version installed\n version requirements: \"(any version)\"\n versions installed: none" - } - ] - } - - expected_output = [ - '::error ::Could not satisfy plugin requirements', - '::error ::providers.null: no suitable version installed' - ] - - output = list(convert_to_github(input, 'terraform')) - assert output == expected_output - -def test_blah(): - input = { - "valid": False, - "error_count": 1, - "warning_count": 0, - "diagnostics": [ - { - "severity": "error", - "summary": "Duplicate resource \"null_resource\" configuration", - "detail": "A null_resource resource named \"hello\" was already declared at main.tf:1,1-33. Resource names must be unique per type in each module.", - "range": { - "filename": "main.tf", - "start": { - "line": 2, - "column": 1, - "byte": 36 - }, - "end": { - "line": 2, - "column": 33, - "byte": 68 - } - } - } - ] - } - - expected_output = [ - '::error file=terraform/main.tf,line=2,col=1::Duplicate resource "null_resource" configuration' - ] - - output = list(convert_to_github(input, 'terraform')) - assert output == expected_output - - -def test_invalid_paths(): - input = { - "valid": False, - "error_count": 2, - "warning_count": 0, - "diagnostics": [ - { - "severity": "error", - "summary": "Duplicate resource \"null_resource\" configuration", - "detail": "A null_resource resource named \"hello\" was already declared at main.tf:1,1-33. Resource names must be unique per type in each module.", - "range": { - "filename": "main.tf", - "start": { - "line": 2, - "column": 1, - "byte": 36 - }, - "end": { - "line": 2, - "column": 33, - "byte": 68 - } - } - }, - { - "severity": "error", - "summary": "Duplicate resource \"null_resource\" configuration", - "detail": "A null_resource resource named \"goodbye\" was already declared at ../module/invalid.tf:1,1-33. Resource names must be unique per type in each module.", - "range": { - "filename": "../module/invalid.tf", - "start": { - "line": 2, - "column": 1, - "byte": 36 - }, - "end": { - "line": 2, - "column": 33, - "byte": 68 - } - } - } - ] - } - - expected_output = [ - '::error file=tests/validate/invalid/main.tf,line=2,col=1::Duplicate resource "null_resource" configuration', - '::error file=tests/validate/module/invalid.tf,line=2,col=1::Duplicate resource "null_resource" configuration' - ] - - output = list(convert_to_github(input, 'tests/validate/invalid')) - assert output == expected_output diff --git a/tests/apply/changes/main.tf b/tests/workflows/pull_request_review/main.tf similarity index 100% rename from tests/apply/changes/main.tf rename to tests/workflows/pull_request_review/main.tf diff --git a/tests/workflows/pull_request_target/main.tf b/tests/workflows/pull_request_target/main.tf new file mode 100644 index 00000000..615bfe89 --- /dev/null +++ b/tests/workflows/pull_request_target/main.tf @@ -0,0 +1,7 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "output_string" { + value = "the_string" +} diff --git a/tests/workflows/test-apply/apply-error/main.tf b/tests/workflows/test-apply/apply-error/main.tf new file mode 100644 index 00000000..dd1b9b25 --- /dev/null +++ b/tests/workflows/test-apply/apply-error/main.tf @@ -0,0 +1,10 @@ +# This should be valid, and generate a valid plan +# But we expect it to fail when applied, as the bucket surely already exists + +resource "aws_s3_bucket" "my_bucket" { + bucket = "hello" +} + +provider aws { + region = "eu-west-2" +} diff --git a/tests/apply/backend_config_12/backend_config b/tests/workflows/test-apply/backend_config_12/backend_config similarity index 100% rename from tests/apply/backend_config_12/backend_config rename to tests/workflows/test-apply/backend_config_12/backend_config diff --git a/tests/apply/backend_config_12/main.tf b/tests/workflows/test-apply/backend_config_12/main.tf similarity index 100% rename from tests/apply/backend_config_12/main.tf rename to tests/workflows/test-apply/backend_config_12/main.tf diff --git a/tests/apply/backend_config_13/backend_config b/tests/workflows/test-apply/backend_config_13/backend_config similarity index 100% rename from tests/apply/backend_config_13/backend_config rename to tests/workflows/test-apply/backend_config_13/backend_config diff --git a/tests/apply/backend_config_13/main.tf b/tests/workflows/test-apply/backend_config_13/main.tf similarity index 100% rename from tests/apply/backend_config_13/main.tf rename to tests/workflows/test-apply/backend_config_13/main.tf diff --git a/tests/workflows/test-apply/changes/main.tf b/tests/workflows/test-apply/changes/main.tf new file mode 100644 index 00000000..615bfe89 --- /dev/null +++ b/tests/workflows/test-apply/changes/main.tf @@ -0,0 +1,7 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "output_string" { + value = "the_string" +} diff --git a/tests/workflows/test-apply/deprecated_var/main.tf b/tests/workflows/test-apply/deprecated_var/main.tf new file mode 100644 index 00000000..2e928fee --- /dev/null +++ b/tests/workflows/test-apply/deprecated_var/main.tf @@ -0,0 +1,44 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "output_string" { + value = "the_string" +} + +variable "my_var" { + type = string + default = "my_var_default" +} + +variable "my_var_from_file" { + type = string + default = "my_var_from_file_default" +} + +variable "complex_input" { + type = list(object({ + internal = number + external = number + protocol = string + })) + default = [ + { + internal = 8300 + external = 8300 + protocol = "tcp" + } + ] +} + +output "from_var" { + value = var.my_var +} + +output "from_varfile" { + value = var.my_var_from_file +} + +output "complex_output" { + value = join(",", [for input in var.complex_input : "${input.internal}:${input.external}:${input.protocol}"]) +} diff --git a/tests/apply/error/main.tf b/tests/workflows/test-apply/error/main.tf similarity index 100% rename from tests/apply/error/main.tf rename to tests/workflows/test-apply/error/main.tf diff --git a/tests/apply/no_changes/main.tf b/tests/workflows/test-apply/no_changes/main.tf similarity index 100% rename from tests/apply/no_changes/main.tf rename to tests/workflows/test-apply/no_changes/main.tf diff --git a/tests/apply/no_plan/main.tf b/tests/workflows/test-apply/no_plan/main.tf similarity index 100% rename from tests/apply/no_plan/main.tf rename to tests/workflows/test-apply/no_plan/main.tf diff --git a/tests/workflows/test-apply/refresh_15/main.tf b/tests/workflows/test-apply/refresh_15/main.tf new file mode 100644 index 00000000..b874b220 --- /dev/null +++ b/tests/workflows/test-apply/refresh_15/main.tf @@ -0,0 +1,13 @@ +resource "random_string" "my_string" { + length = var.len +} + +output "s" { + value = "${random_string.my_string}" +} + +terraform { + required_version = "~> 0.15.0" +} + +variable "len" {} diff --git a/tests/remote-state/test-bucket_12/main.tf b/tests/workflows/test-apply/remote/main.tf similarity index 100% rename from tests/remote-state/test-bucket_12/main.tf rename to tests/workflows/test-apply/remote/main.tf diff --git a/tests/workflows/test-apply/test.tfvars b/tests/workflows/test-apply/test.tfvars new file mode 100644 index 00000000..368d66db --- /dev/null +++ b/tests/workflows/test-apply/test.tfvars @@ -0,0 +1,2 @@ +my_var_from_file="monkey" +my_var="this should be overridden" diff --git a/tests/workflows/test-apply/vars/main.tf b/tests/workflows/test-apply/vars/main.tf new file mode 100644 index 00000000..2e928fee --- /dev/null +++ b/tests/workflows/test-apply/vars/main.tf @@ -0,0 +1,44 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "output_string" { + value = "the_string" +} + +variable "my_var" { + type = string + default = "my_var_default" +} + +variable "my_var_from_file" { + type = string + default = "my_var_from_file_default" +} + +variable "complex_input" { + type = list(object({ + internal = number + external = number + protocol = string + })) + default = [ + { + internal = 8300 + external = 8300 + protocol = "tcp" + } + ] +} + +output "from_var" { + value = var.my_var +} + +output "from_varfile" { + value = var.my_var_from_file +} + +output "complex_output" { + value = join(",", [for input in var.complex_input : "${input.internal}:${input.external}:${input.protocol}"]) +} diff --git a/tests/workflows/test-changes-only/main.tf b/tests/workflows/test-changes-only/main.tf new file mode 100644 index 00000000..e5192d4f --- /dev/null +++ b/tests/workflows/test-changes-only/main.tf @@ -0,0 +1,12 @@ +variable "cause-changes" { + default = false +} + +variable "len" { + default = 5 +} + +resource "random_string" "the_string" { + count = var.cause-changes ? 1 : 0 + length = var.len +} diff --git a/tests/plan/plan/main.tf b/tests/workflows/test-check/changes/main.tf similarity index 100% rename from tests/plan/plan/main.tf rename to tests/workflows/test-check/changes/main.tf diff --git a/tests/plan/no_changes/main.tf b/tests/workflows/test-check/no_changes/main.tf similarity index 100% rename from tests/plan/no_changes/main.tf rename to tests/workflows/test-check/no_changes/main.tf diff --git a/tests/workflows/test-cloud/0.13/main.tf b/tests/workflows/test-cloud/0.13/main.tf new file mode 100644 index 00000000..f8c913cb --- /dev/null +++ b/tests/workflows/test-cloud/0.13/main.tf @@ -0,0 +1,38 @@ +terraform { + backend "remote" { + organization = "flooktech" + + workspaces { + prefix = "github-actions-0-13-" + } + } + required_version = "0.13.0" +} + +resource "random_id" "the_id" { + byte_length = 5 +} + +variable "default" { + default = "default" +} + +output "default" { + value = var.default +} + +variable "from_tfvars" { + default = "default" +} + +output "from_tfvars" { + value = var.from_tfvars +} + +variable "from_variables" { + default = "default" +} + +output "from_variables" { + value = var.from_variables +} diff --git a/tests/workflows/test-cloud/0.13/my_variable.tfvars b/tests/workflows/test-cloud/0.13/my_variable.tfvars new file mode 100644 index 00000000..8fa611eb --- /dev/null +++ b/tests/workflows/test-cloud/0.13/my_variable.tfvars @@ -0,0 +1,2 @@ +from_tfvars="from_tfvars" +from_variables="from_tfvars" diff --git a/tests/workflows/test-cloud/1.0/main.tf b/tests/workflows/test-cloud/1.0/main.tf new file mode 100644 index 00000000..62e605cb --- /dev/null +++ b/tests/workflows/test-cloud/1.0/main.tf @@ -0,0 +1,38 @@ +terraform { + backend "remote" { + organization = "flooktech" + + workspaces { + prefix = "github-actions-1-1-" + } + } + required_version = "~> 1.0.0" +} + +resource "random_id" "the_id" { + byte_length = 5 +} + +variable "default" { + default = "default" +} + +output "default" { + value = var.default +} + +variable "from_tfvars" { + default = "default" +} + +output "from_tfvars" { + value = var.from_tfvars +} + +variable "from_variables" { + default = "default" +} + +output "from_variables" { + value = var.from_variables +} diff --git a/tests/workflows/test-cloud/1.0/my_variable.tfvars b/tests/workflows/test-cloud/1.0/my_variable.tfvars new file mode 100644 index 00000000..8fa611eb --- /dev/null +++ b/tests/workflows/test-cloud/1.0/my_variable.tfvars @@ -0,0 +1,2 @@ +from_tfvars="from_tfvars" +from_variables="from_tfvars" diff --git a/tests/workflows/test-cloud/1.1/main.tf b/tests/workflows/test-cloud/1.1/main.tf new file mode 100644 index 00000000..e830f003 --- /dev/null +++ b/tests/workflows/test-cloud/1.1/main.tf @@ -0,0 +1,38 @@ +terraform { + backend "remote" { + organization = "flooktech" + + workspaces { + prefix = "github-actions-1-1" + } + } + required_version = "1.1.0" +} + +resource "random_id" "the_id" { + byte_length = 5 +} + +variable "default" { + default = "default" +} + +output "default" { + value = var.default +} + +variable "from_tfvars" { + default = "default" +} + +output "from_tfvars" { + value = var.from_tfvars +} + +variable "from_variables" { + default = "default" +} + +output "from_variables" { + value = var.from_variables +} diff --git a/tests/workflows/test-cloud/1.1/my_variable.tfvars b/tests/workflows/test-cloud/1.1/my_variable.tfvars new file mode 100644 index 00000000..8fa611eb --- /dev/null +++ b/tests/workflows/test-cloud/1.1/my_variable.tfvars @@ -0,0 +1,2 @@ +from_tfvars="from_tfvars" +from_variables="from_tfvars" diff --git a/tests/fmt/canonical/main.tf b/tests/workflows/test-fmt-check/canonical/main.tf similarity index 100% rename from tests/fmt/canonical/main.tf rename to tests/workflows/test-fmt-check/canonical/main.tf diff --git a/tests/fmt/canonical/subdir/main.tf b/tests/workflows/test-fmt-check/canonical/subdir/main.tf similarity index 100% rename from tests/fmt/canonical/subdir/main.tf rename to tests/workflows/test-fmt-check/canonical/subdir/main.tf diff --git a/tests/fmt/non-canonical/main.tf b/tests/workflows/test-fmt-check/non-canonical/main.tf similarity index 100% rename from tests/fmt/non-canonical/main.tf rename to tests/workflows/test-fmt-check/non-canonical/main.tf diff --git a/tests/fmt/non-canonical/subdir/main.tf b/tests/workflows/test-fmt-check/non-canonical/subdir/main.tf similarity index 100% rename from tests/fmt/non-canonical/subdir/main.tf rename to tests/workflows/test-fmt-check/non-canonical/subdir/main.tf diff --git a/tests/workflows/test-fmt/canonical/main.tf b/tests/workflows/test-fmt/canonical/main.tf new file mode 100644 index 00000000..5cc55884 --- /dev/null +++ b/tests/workflows/test-fmt/canonical/main.tf @@ -0,0 +1,4 @@ +resource "aws_s3_bucket" "hello" { + bucket = "asd" + bucket_prefix = "hgd" +} diff --git a/tests/workflows/test-fmt/canonical/subdir/main.tf b/tests/workflows/test-fmt/canonical/subdir/main.tf new file mode 100644 index 00000000..5cc55884 --- /dev/null +++ b/tests/workflows/test-fmt/canonical/subdir/main.tf @@ -0,0 +1,4 @@ +resource "aws_s3_bucket" "hello" { + bucket = "asd" + bucket_prefix = "hgd" +} diff --git a/tests/workflows/test-fmt/non-canonical/main.tf b/tests/workflows/test-fmt/non-canonical/main.tf new file mode 100644 index 00000000..46d6e863 --- /dev/null +++ b/tests/workflows/test-fmt/non-canonical/main.tf @@ -0,0 +1,10 @@ +resource "aws_s3_bucket" "hello" { + bucket = "asd" + bucket_prefix = "hgd" +} + +variable "test-var" { + type = string + description = "A test variable that is formatted wrong" + +} \ No newline at end of file diff --git a/tests/workflows/test-fmt/non-canonical/subdir/main.tf b/tests/workflows/test-fmt/non-canonical/subdir/main.tf new file mode 100644 index 00000000..bca928f7 --- /dev/null +++ b/tests/workflows/test-fmt/non-canonical/subdir/main.tf @@ -0,0 +1,4 @@ +resource "aws_s3_bucket" "hello" { + bucket = "asd" + bucket_prefix = "hgd" +} diff --git a/tests/workflows/test-http/http-module/main.tf b/tests/workflows/test-http/http-module/main.tf new file mode 100644 index 00000000..b2dfc173 --- /dev/null +++ b/tests/workflows/test-http/http-module/main.tf @@ -0,0 +1,7 @@ +module "https_source" { + source = "https://5qcb7mjppk.execute-api.eu-west-2.amazonaws.com/my_module" +} + +output "https" { + value = module.https_source.my-output +} diff --git a/tests/workflows/test-http/main.tf b/tests/workflows/test-http/main.tf new file mode 100644 index 00000000..e7c52c01 --- /dev/null +++ b/tests/workflows/test-http/main.tf @@ -0,0 +1,7 @@ +module "git_https_source" { + source = "git::https://github.com/dflook/terraform-github-actions-dev.git//tests/workflows/test-http/test-module" +} + +output "git_https" { + value = module.git_https_source.my-output +} diff --git a/tests/workflows/test-http/test-module/README.md b/tests/workflows/test-http/test-module/README.md new file mode 100644 index 00000000..aebfdf32 --- /dev/null +++ b/tests/workflows/test-http/test-module/README.md @@ -0,0 +1 @@ +This module is hosted in a git repo diff --git a/tests/workflows/test-http/test-module/main.tf b/tests/workflows/test-http/test-module/main.tf new file mode 100644 index 00000000..4cecf271 --- /dev/null +++ b/tests/workflows/test-http/test-module/main.tf @@ -0,0 +1,3 @@ +output "my-output" { + value = "hello" +} diff --git a/tests/workflows/test-new-workspace/main.tf b/tests/workflows/test-new-workspace/main.tf new file mode 100644 index 00000000..899e2ca4 --- /dev/null +++ b/tests/workflows/test-new-workspace/main.tf @@ -0,0 +1,11 @@ +resource "random_string" "my_string" { + length = 5 +} + +variable "my_string" { + type = string +} + +output "my_string" { + value = var.my_string +} diff --git a/tests/workflows/test-output/main.tf b/tests/workflows/test-output/main.tf new file mode 100644 index 00000000..0aa9bc90 --- /dev/null +++ b/tests/workflows/test-output/main.tf @@ -0,0 +1,116 @@ +terraform { + backend "s3" { + bucket = "terraform-github-actions" + key = "terraform-remote-state" + region = "eu-west-2" + } + required_version = "~> 0.12.0" +} + +output "my_number" { + value = 5 +} + +output "my_sensitive_number" { + value = 6 + sensitive = true +} + +output "my_string" { + value = "hello" +} + +output "my_sensitive_string" { + value = "password" + sensitive = true +} + +output "my_bool" { + value = true +} + +output "my_sensitive_bool" { + value = false + sensitive = true +} + +output "my_list" { + value = tolist(toset(["one", "two"])) +} + +output "my_sensitive_list" { + value = tolist(toset(["one", "two"])) + sensitive = true +} + +output "my_map" { + value = tomap({ + first = "one" + second = "two" + third = 3 + }) +} + +output "my_sensitive_map" { + value = tomap({ + first = "one" + second = "two" + third = 3 + }) + sensitive = true +} + +output "my_set" { + value = toset(["one", "two"]) +} + +output "my_sensitive_set" { + value = toset(["one", "two"]) + sensitive = true +} + +output "my_object" { + value = { + first = "one" + second = "two" + third = 3 + } +} + +output "my_sensitive_object" { + value = { + first = "one" + second = "two" + third = 3 + } + sensitive = true +} + +output "my_tuple" { + value = ["one", "two"] +} + +output "my_sensitive_tuple" { + value = ["one", "two"] + sensitive = true +} + +output "my_compound_output" { + value = { + first = tolist(toset(["one", "two"])) + second = toset(["one", "two"]) + third = 3 + } +} + +output "awkward_string" { + value = "hello \"there\", here are some 'quotes'." +} + +output "awkward_compound_output" { + value = { + nested = { + thevalue = ["hello \"there\", here are some 'quotes'."] + } + } +} diff --git a/tests/workflows/test-plan/changes-only/main.tf b/tests/workflows/test-plan/changes-only/main.tf new file mode 100644 index 00000000..e5192d4f --- /dev/null +++ b/tests/workflows/test-plan/changes-only/main.tf @@ -0,0 +1,12 @@ +variable "cause-changes" { + default = false +} + +variable "len" { + default = 5 +} + +resource "random_string" "the_string" { + count = var.cause-changes ? 1 : 0 + length = var.len +} diff --git a/tests/plan/error/main.tf b/tests/workflows/test-plan/error/main.tf similarity index 100% rename from tests/plan/error/main.tf rename to tests/workflows/test-plan/error/main.tf diff --git a/tests/workflows/test-plan/no_changes/main.tf b/tests/workflows/test-plan/no_changes/main.tf new file mode 100644 index 00000000..646825e0 --- /dev/null +++ b/tests/workflows/test-plan/no_changes/main.tf @@ -0,0 +1,3 @@ +locals { + hello = "world" +} \ No newline at end of file diff --git a/tests/workflows/test-plan/plan/main.tf b/tests/workflows/test-plan/plan/main.tf new file mode 100644 index 00000000..dee08246 --- /dev/null +++ b/tests/workflows/test-plan/plan/main.tf @@ -0,0 +1,7 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +} diff --git a/tests/workflows/test-plan/plan_11/main.tf b/tests/workflows/test-plan/plan_11/main.tf new file mode 100644 index 00000000..a5a0f32f --- /dev/null +++ b/tests/workflows/test-plan/plan_11/main.tf @@ -0,0 +1,11 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +} + +terraform { + required_version = "~> 0.11.0" +} diff --git a/tests/workflows/test-plan/plan_12/main.tf b/tests/workflows/test-plan/plan_12/main.tf new file mode 100644 index 00000000..33afe2d1 --- /dev/null +++ b/tests/workflows/test-plan/plan_12/main.tf @@ -0,0 +1,11 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +} + +terraform { + required_version = "~> 0.12.0" +} diff --git a/tests/workflows/test-plan/plan_13/main.tf b/tests/workflows/test-plan/plan_13/main.tf new file mode 100644 index 00000000..9b4048c5 --- /dev/null +++ b/tests/workflows/test-plan/plan_13/main.tf @@ -0,0 +1,11 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +} + +terraform { + required_version = "~> 0.13.0" +} diff --git a/tests/workflows/test-plan/plan_14/main.tf b/tests/workflows/test-plan/plan_14/main.tf new file mode 100644 index 00000000..cbcbb864 --- /dev/null +++ b/tests/workflows/test-plan/plan_14/main.tf @@ -0,0 +1,11 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +} + +terraform { + required_version = "~> 0.14.0" +} diff --git a/tests/workflows/test-plan/plan_15/main.tf b/tests/workflows/test-plan/plan_15/main.tf new file mode 100644 index 00000000..f3fcb3bc --- /dev/null +++ b/tests/workflows/test-plan/plan_15/main.tf @@ -0,0 +1,11 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +} + +terraform { + required_version = "0.15.3" +} diff --git a/tests/workflows/test-plan/plan_15_4/main.tf b/tests/workflows/test-plan/plan_15_4/main.tf new file mode 100644 index 00000000..36a07973 --- /dev/null +++ b/tests/workflows/test-plan/plan_15_4/main.tf @@ -0,0 +1,11 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +} + +terraform { + required_version = "~>0.15.4" +} diff --git a/tests/workflows/test-plan/test.tfvars b/tests/workflows/test-plan/test.tfvars new file mode 100644 index 00000000..368d66db --- /dev/null +++ b/tests/workflows/test-plan/test.tfvars @@ -0,0 +1,2 @@ +my_var_from_file="monkey" +my_var="this should be overridden" diff --git a/tests/workflows/test-plan/vars/main.tf b/tests/workflows/test-plan/vars/main.tf new file mode 100644 index 00000000..2e928fee --- /dev/null +++ b/tests/workflows/test-plan/vars/main.tf @@ -0,0 +1,44 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "output_string" { + value = "the_string" +} + +variable "my_var" { + type = string + default = "my_var_default" +} + +variable "my_var_from_file" { + type = string + default = "my_var_from_file_default" +} + +variable "complex_input" { + type = list(object({ + internal = number + external = number + protocol = string + })) + default = [ + { + internal = 8300 + external = 8300 + protocol = "tcp" + } + ] +} + +output "from_var" { + value = var.my_var +} + +output "from_varfile" { + value = var.my_var_from_file +} + +output "complex_output" { + value = join(",", [for input in var.complex_input : "${input.internal}:${input.external}:${input.protocol}"]) +} diff --git a/tests/workflows/test-registry/main.tf b/tests/workflows/test-registry/main.tf new file mode 100644 index 00000000..2b331600 --- /dev/null +++ b/tests/workflows/test-registry/main.tf @@ -0,0 +1,8 @@ +module "hello" { + source = "app.terraform.io/flooktech/test/aws" + version = "0.0.1" +} + +output "word" { + value = module.hello.my-output +} diff --git a/tests/workflows/test-registry/test-module/README.md b/tests/workflows/test-registry/test-module/README.md new file mode 100644 index 00000000..f8038413 --- /dev/null +++ b/tests/workflows/test-registry/test-module/README.md @@ -0,0 +1 @@ +This module is hosted in a registry that requires a token to access diff --git a/tests/workflows/test-registry/test-module/main.tf b/tests/workflows/test-registry/test-module/main.tf new file mode 100644 index 00000000..4cecf271 --- /dev/null +++ b/tests/workflows/test-registry/test-module/main.tf @@ -0,0 +1,3 @@ +output "my-output" { + value = "hello" +} diff --git a/tests/workflows/test-ssh/main.tf b/tests/workflows/test-ssh/main.tf new file mode 100644 index 00000000..c8dd0374 --- /dev/null +++ b/tests/workflows/test-ssh/main.tf @@ -0,0 +1,7 @@ +module "hello" { + source = "git::ssh://git@github.com/dflook/terraform-github-actions//tests/workflows/test-ssh/test-module" +} + +output "word" { + value = module.hello.my-output +} diff --git a/tests/workflows/test-ssh/test-module/README.md b/tests/workflows/test-ssh/test-module/README.md new file mode 100644 index 00000000..6aa9053f --- /dev/null +++ b/tests/workflows/test-ssh/test-module/README.md @@ -0,0 +1 @@ +This module is a module stored in a git repo diff --git a/tests/workflows/test-ssh/test-module/main.tf b/tests/workflows/test-ssh/test-module/main.tf new file mode 100644 index 00000000..4cecf271 --- /dev/null +++ b/tests/workflows/test-ssh/test-module/main.tf @@ -0,0 +1,3 @@ +output "my-output" { + value = "hello" +} diff --git a/tests/workflows/test-target-replace/main.tf b/tests/workflows/test-target-replace/main.tf new file mode 100644 index 00000000..16fd8bd3 --- /dev/null +++ b/tests/workflows/test-target-replace/main.tf @@ -0,0 +1,29 @@ +resource "random_string" "count" { + count = 1 + + length = var.length + + special = false + min_special = 0 +} + +resource "random_string" "foreach" { + for_each = toset(["hello"]) + + length = var.length + + special = false + min_special = 0 +} + +variable "length" { + +} + +output "count" { + value = random_string.count[0].result +} + +output "foreach" { + value = random_string.foreach["hello"].result +} diff --git a/tests/validate/invalid/main.tf b/tests/workflows/test-validate/invalid/main.tf similarity index 100% rename from tests/validate/invalid/main.tf rename to tests/workflows/test-validate/invalid/main.tf diff --git a/tests/validate/report/error.json b/tests/workflows/test-validate/report/error.json similarity index 100% rename from tests/validate/report/error.json rename to tests/workflows/test-validate/report/error.json diff --git a/tests/validate/report/file_location.json b/tests/workflows/test-validate/report/file_location.json similarity index 100% rename from tests/validate/report/file_location.json rename to tests/workflows/test-validate/report/file_location.json diff --git a/tests/validate/report/line_num.json b/tests/workflows/test-validate/report/line_num.json similarity index 100% rename from tests/validate/report/line_num.json rename to tests/workflows/test-validate/report/line_num.json diff --git a/tests/validate/report/non_json.txt b/tests/workflows/test-validate/report/non_json.txt similarity index 100% rename from tests/validate/report/non_json.txt rename to tests/workflows/test-validate/report/non_json.txt diff --git a/tests/validate/report/test_convert_validate_report.sh b/tests/workflows/test-validate/report/test_convert_validate_report.sh similarity index 100% rename from tests/validate/report/test_convert_validate_report.sh rename to tests/workflows/test-validate/report/test_convert_validate_report.sh diff --git a/tests/validate/report/valid.json b/tests/workflows/test-validate/report/valid.json similarity index 100% rename from tests/validate/report/valid.json rename to tests/workflows/test-validate/report/valid.json diff --git a/tests/workflows/test-validate/unterminated-string/main.tf b/tests/workflows/test-validate/unterminated-string/main.tf new file mode 100644 index 00000000..84efaccf --- /dev/null +++ b/tests/workflows/test-validate/unterminated-string/main.tf @@ -0,0 +1,10 @@ +module "enforce_mfa" { + source = "terraform-module/enforce-mfa/aws" + version = "0.13.0” + policy_name = "managed-mfa-enforce" + account_id = data.aws_caller_identity.current.id + groups = [aws_iam_group.console_group.name] + manage_own_signing_certificates = true + manage_own_ssh_public_keys = true + manage_own_git_credentials = true + } diff --git a/tests/validate/valid/main.tf b/tests/workflows/test-validate/valid/main.tf similarity index 100% rename from tests/validate/valid/main.tf rename to tests/workflows/test-validate/valid/main.tf diff --git a/tests/workflows/test-validate/workspace_eval/main.tf b/tests/workflows/test-validate/workspace_eval/main.tf new file mode 100644 index 00000000..461a726d --- /dev/null +++ b/tests/workflows/test-validate/workspace_eval/main.tf @@ -0,0 +1,24 @@ +locals { + aws_provider_config = { + prod = { + region = "..." + account_id = "..." + profile = "..." + } + dev = { + region = "..." + account_id = "..." + profile = "..." + } + } +} + +provider "aws" { + region = local.aws_provider_config[terraform.workspace].region + profile = local.aws_provider_config[terraform.workspace].profile + allowed_account_ids = [local.aws_provider_config[terraform.workspace].account_id] +} + +resource "aws_s3_bucket" "bucket" { + bucket = "hello" +} diff --git a/tests/workflows/test-validate/workspace_eval_remote/main.tf b/tests/workflows/test-validate/workspace_eval_remote/main.tf new file mode 100644 index 00000000..e49faf6f --- /dev/null +++ b/tests/workflows/test-validate/workspace_eval_remote/main.tf @@ -0,0 +1,31 @@ +locals { + aws_provider_config = { + default = { + region = "..." + account_id = "..." + profile = "..." + } + } +} + +provider "aws" { + region = local.aws_provider_config[terraform.workspace].region + profile = local.aws_provider_config[terraform.workspace].profile + allowed_account_ids = [local.aws_provider_config[terraform.workspace].account_id] +} + +resource "aws_s3_bucket" "bucket" { + bucket = "hello" +} + +terraform { + backend "remote" { + hostname = "app.terraform.io" + organization = "flooktech" + + workspaces { + name = "banana" + } + } +} + diff --git a/tests/workflows/test-version/asdf/.tool-versions b/tests/workflows/test-version/asdf/.tool-versions new file mode 100644 index 00000000..f9eddc6a --- /dev/null +++ b/tests/workflows/test-version/asdf/.tool-versions @@ -0,0 +1,5 @@ + + nothing 1.0.0 + + terraform 0.12.11 # woo + diff --git a/tests/workflows/test-version/cloud/main.tf b/tests/workflows/test-version/cloud/main.tf new file mode 100644 index 00000000..8203bbe8 --- /dev/null +++ b/tests/workflows/test-version/cloud/main.tf @@ -0,0 +1,8 @@ +terraform { + cloud { + organization = "flooktech" + workspaces { + tags = ["terraformgithubactions", "version", "cloud"] + } + } +} diff --git a/tests/version/empty/README.md b/tests/workflows/test-version/empty/README.md similarity index 100% rename from tests/version/empty/README.md rename to tests/workflows/test-version/empty/README.md diff --git a/tests/workflows/test-version/local/main.tf b/tests/workflows/test-version/local/main.tf new file mode 100644 index 00000000..56b62e82 --- /dev/null +++ b/tests/workflows/test-version/local/main.tf @@ -0,0 +1,4 @@ +variable "my_variable" {} +output "out" { + value = "${var.my_variable}" +} diff --git a/tests/workflows/test-version/local/terraform.tfstate b/tests/workflows/test-version/local/terraform.tfstate new file mode 100644 index 00000000..18fad48a --- /dev/null +++ b/tests/workflows/test-version/local/terraform.tfstate @@ -0,0 +1,13 @@ +{ + "version": 4, + "terraform_version": "0.15.4", + "serial": 1, + "lineage": "9020ad30-7aa7-e1ac-3f14-98e8220a1545", + "outputs": { + "out": { + "value": "hello", + "type": "string" + } + }, + "resources": [] +} diff --git a/tests/version/providers/0.11/main.tf b/tests/workflows/test-version/providers/0.11/main.tf similarity index 69% rename from tests/version/providers/0.11/main.tf rename to tests/workflows/test-version/providers/0.11/main.tf index 5ab0b1b9..124ea087 100644 --- a/tests/version/providers/0.11/main.tf +++ b/tests/workflows/test-version/providers/0.11/main.tf @@ -6,5 +6,5 @@ provider "acme" { } terraform { - required_version = "~>0.11" -} \ No newline at end of file + required_version = "~>0.11.0" +} diff --git a/tests/version/providers/0.12/main.tf b/tests/workflows/test-version/providers/0.12/main.tf similarity index 69% rename from tests/version/providers/0.12/main.tf rename to tests/workflows/test-version/providers/0.12/main.tf index c4a99566..a76fe997 100644 --- a/tests/version/providers/0.12/main.tf +++ b/tests/workflows/test-version/providers/0.12/main.tf @@ -6,5 +6,5 @@ provider "acme" { } terraform { - required_version = "~>0.12" -} \ No newline at end of file + required_version = "~>0.12.0" +} diff --git a/tests/version/providers/0.13/versions.tf b/tests/workflows/test-version/providers/0.13/versions.tf similarity index 84% rename from tests/version/providers/0.13/versions.tf rename to tests/workflows/test-version/providers/0.13/versions.tf index e923b367..4c035daa 100644 --- a/tests/version/providers/0.13/versions.tf +++ b/tests/workflows/test-version/providers/0.13/versions.tf @@ -8,5 +8,5 @@ terraform { version = "2.2.0" } } - required_version = "~> 0.13" + required_version = "~> 0.13.0" } diff --git a/tests/version/range/main.tf b/tests/workflows/test-version/range/main.tf similarity index 100% rename from tests/version/range/main.tf rename to tests/workflows/test-version/range/main.tf diff --git a/tests/version/required_version/main.tf b/tests/workflows/test-version/required_version/main.tf similarity index 100% rename from tests/version/required_version/main.tf rename to tests/workflows/test-version/required_version/main.tf diff --git a/tests/workflows/test-version/state/main.tf b/tests/workflows/test-version/state/main.tf new file mode 100644 index 00000000..fc02fa43 --- /dev/null +++ b/tests/workflows/test-version/state/main.tf @@ -0,0 +1,13 @@ +variable "my_variable" {} +output "out" { + value = "${var.my_variable}" +} + +terraform { + backend "s3" { + bucket = "terraform-github-actions" + key = "terraform-version" + region = "eu-west-2" + dynamodb_table = "terraform-github-actions" + } +} diff --git a/tests/workflows/test-version/terraform-cloud/main.tf b/tests/workflows/test-version/terraform-cloud/main.tf new file mode 100644 index 00000000..03a4bc49 --- /dev/null +++ b/tests/workflows/test-version/terraform-cloud/main.tf @@ -0,0 +1,9 @@ +terraform { + backend "remote" { + organization = "flooktech" + + workspaces { + prefix = "github-actions-version-" + } + } +} diff --git a/tests/version/tfenv/.tfswitchrc b/tests/workflows/test-version/tfenv/.terraform-version similarity index 100% rename from tests/version/tfenv/.tfswitchrc rename to tests/workflows/test-version/tfenv/.terraform-version diff --git a/tests/workflows/test-version/tfenv/main.tf b/tests/workflows/test-version/tfenv/main.tf new file mode 100644 index 00000000..e69de29b diff --git a/tests/version/tfswitch/.tfswitchrc b/tests/workflows/test-version/tfswitch/.tfswitchrc similarity index 100% rename from tests/version/tfswitch/.tfswitchrc rename to tests/workflows/test-version/tfswitch/.tfswitchrc diff --git a/tests/workflows/test-version/tfswitch/main.tf b/tests/workflows/test-version/tfswitch/main.tf new file mode 100644 index 00000000..e69de29b diff --git a/tests/workflows/test-workflow-commands/main.tf b/tests/workflows/test-workflow-commands/main.tf new file mode 100644 index 00000000..dee08246 --- /dev/null +++ b/tests/workflows/test-workflow-commands/main.tf @@ -0,0 +1,7 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +}