diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 6a70b34e88..fd73005ef8 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -9,7 +9,8 @@ automerge: true, baseBranches: [ 'main', - '/^release-.*/', + 'release-0.31', + 'release-0.32', ], platformAutomerge: true, labels: [ diff --git a/.github/workflows/atlantis-image.yml b/.github/workflows/atlantis-image.yml index 02f0f2dcec..ef18a97b78 100644 --- a/.github/workflows/atlantis-image.yml +++ b/.github/workflows/atlantis-image.yml @@ -29,6 +29,11 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes @@ -61,6 +66,11 @@ jobs: PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) }} steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 # Lint the Dockerfile first before setting anything up @@ -69,8 +79,13 @@ jobs: with: dockerfile: "Dockerfile" + - name: Set up Go + uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version-file: "go.mod" + - name: Set up QEMU - uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3 + uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3 with: image: tonistiigi/binfmt:latest platforms: arm64,arm @@ -82,6 +97,10 @@ jobs: driver-opts: | image=moby/buildkit:v0.14.0 + - name: "Install cosign" + uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 + if: env.PUSH == 'true' && github.event_name != 'pull_request' + # release version is the name of the tag i.e. v0.10.0 # release version also has the image type appended i.e. v0.10.0-alpine # release tag is either pre-release or latest i.e. latest @@ -136,7 +155,7 @@ jobs: - name: "Build ${{ env.PUSH == 'true' && 'and push' || '' }} ${{ env.DOCKER_REPO }} image" id: build if: contains(fromJson('["push", "pull_request"]'), github.event_name) - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6 with: cache-from: type=gha cache-to: type=gha,mode=max @@ -146,21 +165,36 @@ jobs: ATLANTIS_VERSION=${{ env.RELEASE_VERSION }} ATLANTIS_COMMIT=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} ATLANTIS_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} - platforms: linux/arm64/v8,linux/amd64,linux/arm/v7 + platforms: linux/arm64/v8, linux/amd64, linux/arm/v7 push: ${{ env.PUSH }} tags: ${{ steps.meta.outputs.tags }} target: ${{ matrix.image_type }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.description'] }} - - name: "Sign and Attest Image" - if: env.PUSH == 'true' + - name: "Create Image Attestation" + if: env.PUSH == 'true' && github.event_name != 'pull_request' uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0 with: subject-digest: ${{ steps.build.outputs.digest }} subject-name: ghcr.io/${{ github.repository }} push-to-registry: true + - name: "Sign images with environment annotations" + # no key needed, we're using the GitHub OIDC flow + if: env.PUSH == 'true' && github.event_name != 'pull_request' + run: | + # Sign dev tags, version tags, and latest tags + echo "${TAGS}" | xargs -I {} cosign sign \ + --yes \ + -a actor=${{ github.actor}} \ + -a ref_name=${{ github.ref_name}} \ + -a ref=${{ github.sha }} \ + {}@${DIGEST} + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build.outputs.digest }} + test: needs: [changes] if: needs.changes.outputs.should-run-build == 'true' @@ -169,13 +203,18 @@ jobs: strategy: matrix: image_type: [alpine, debian] + platform: [linux/arm64/v8, linux/amd64, linux/arm/v7] env: # Set docker repo to either the fork or the main repo where the branch exists DOCKER_REPO: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3 # https://github.com/docker/build-push-action/issues/761#issuecomment-1575006515 @@ -185,7 +224,7 @@ jobs: - name: "Build and load into Docker" if: contains(fromJson('["push", "pull_request"]'), github.event_name) - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6 with: cache-from: type=gha cache-to: type=gha,mode=max @@ -215,4 +254,10 @@ jobs: image_type: [alpine, debian] runs-on: ubuntu-24.04 steps: - - run: 'echo "No build required"' \ No newline at end of file + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + + - run: 'echo "No build required"' + diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a24704228d..15ec766647 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -43,6 +43,11 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes @@ -72,12 +77,17 @@ jobs: # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3 + uses: github/codeql-action/init@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -91,7 +101,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3 + uses: github/codeql-action/autobuild@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -104,7 +114,7 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3 + uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3 with: category: "/language:${{matrix.language}}" @@ -117,4 +127,9 @@ jobs: language: [ 'go', 'javascript' ] runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - run: 'echo "No build required"' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000000..1b495dbce7 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,27 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, +# PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + + - name: 'Checkout Repository' + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: 'Dependency Review' + uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index de8bd74352..1097aa7f9f 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -19,4 +19,9 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bcfb8bc3c0..951538e9b6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,6 +30,11 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes @@ -47,15 +52,20 @@ jobs: name: Linting runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 # need to setup go toolchain explicitly - - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5 with: go-version-file: go.mod - name: golangci-lint - uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6 + uses: golangci/golangci-lint-action@ec5d18412c0aeab7936cb16880d708ba2a64e1ae # v6 with: # renovate: datasource=github-releases depName=golangci/golangci-lint version: v1.62.2 @@ -66,4 +76,9 @@ jobs: name: Linting runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - run: 'echo "No build required"' diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 6ec8adfc59..470135b108 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -15,6 +15,11 @@ jobs: name: Validate PR title runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-size-labeler.yml b/.github/workflows/pr-size-labeler.yml index 4e48b776d9..79e85a92a3 100644 --- a/.github/workflows/pr-size-labeler.yml +++ b/.github/workflows/pr-size-labeler.yml @@ -12,6 +12,11 @@ jobs: runs-on: ubuntu-latest name: Label the PR size steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: codelytv/pr-size-labeler@c7a55a022747628b50f3eb5bf863b9e796b8f274 # v1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 255b5c5209..1a4d08613b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,11 +10,16 @@ jobs: goreleaser: runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: submodules: true - - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5 with: go-version-file: go.mod diff --git a/.github/workflows/renovate-config.yml b/.github/workflows/renovate-config.yml index 06283df876..6122970549 100644 --- a/.github/workflows/renovate-config.yml +++ b/.github/workflows/renovate-config.yml @@ -19,6 +19,11 @@ jobs: validate: runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4 - run: npx --package renovate -c 'renovate-config-validator' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b3cfe0671e..df1fdaed38 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -19,6 +19,11 @@ jobs: id-token: write steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2 + with: + egress-policy: audit + - name: 'Checkout code' uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 0236da84c9..aedbc5f510 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,12 @@ jobs: pull-requests: write # for actions/stale to close stale PRs runs-on: ubuntu-24.04 steps: - - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9 + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 with: stale-pr-message: 'This issue is stale because it has been open for 1 month with no activity. Remove stale label or comment or this will be closed in 1 month.' stale-issue-message: This issue is stale because it has been open for 1 month with no activity. Remove stale label or comment or this will be closed in 1 month.' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c151d134e..80eb760d68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,11 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes @@ -48,12 +53,17 @@ jobs: if: needs.changes.outputs.should-run-tests == 'true' name: Tests runs-on: ubuntu-24.04 - container: ghcr.io/runatlantis/testing-env:latest@sha256:79991418aec4e5dcb1f18dc7b7bdf6ee37302a30a1e374c7bcf3eba9aadef68d + container: ghcr.io/runatlantis/testing-env:latest@sha256:e6bfa93e7b649feb2f209cb67b24245bcd89a3bb27411ee1402206b14b4358c1 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 # need to setup go toolchain explicitly - - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5 with: go-version-file: go.mod @@ -106,6 +116,11 @@ jobs: name: Tests runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - run: 'echo "No build required"' e2e-github: @@ -118,8 +133,13 @@ jobs: ATLANTIS_GH_TOKEN: ${{ secrets.ATLANTISBOT_GITHUB_TOKEN }} NGROK_AUTH_TOKEN: ${{ secrets.ATLANTISBOT_NGROK_AUTH_TOKEN }} steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5 with: go-version-file: go.mod @@ -155,8 +175,13 @@ jobs: ATLANTIS_GITLAB_TOKEN: ${{ secrets.ATLANTISBOT_GITLAB_TOKEN }} NGROK_AUTH_TOKEN: ${{ secrets.ATLANTISBOT_NGROK_AUTH_TOKEN }} steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5 with: go-version-file: go.mod diff --git a/.github/workflows/testing-env-image.yml b/.github/workflows/testing-env-image.yml index 44008e8a8b..e42cdab80a 100644 --- a/.github/workflows/testing-env-image.yml +++ b/.github/workflows/testing-env-image.yml @@ -15,6 +15,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + contents: read + jobs: changes: permissions: @@ -25,6 +28,11 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes @@ -40,10 +48,15 @@ jobs: name: Build Testing Env Image runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up QEMU - uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3 + uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3 with: image: tonistiigi/binfmt:latest platforms: arm64,arm @@ -60,7 +73,7 @@ jobs: - run: echo "TODAY=$(date +"%Y.%m.%d")" >> $GITHUB_ENV - name: Build and push testing-env:${{env.TODAY}} image - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6 with: cache-from: type=gha cache-to: type=gha,mode=max @@ -77,4 +90,9 @@ jobs: name: Build Testing Env Image runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - run: 'echo "No build required"' diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index afef23747f..f67514bd8e 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -26,6 +26,11 @@ jobs: if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes @@ -46,16 +51,21 @@ jobs: name: Website Link Check runs-on: ubuntu-latest steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: markdown-lint - uses: DavidAnson/markdownlint-cli2-action@eb5ca3ab411449c66620fe7f1b3c9e10547144b0 # v18 + uses: DavidAnson/markdownlint-cli2-action@05f32210e84442804257b2a6f20b273450ec8265 # v19 with: config: .markdownlint.yaml globs: 'runatlantis.io/**/*.md' - name: setup npm - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4 with: node-version: '20' cache: 'npm' @@ -63,7 +73,7 @@ jobs: - name: run http-server env: # renovate: datasource=github-releases depName=raviqqe/muffet - MUFFET_VERSION: 2.10.6 + MUFFET_VERSION: 2.10.7 run: | # install raviqqe/muffet to check for broken links. curl -Ls https://github.com/raviqqe/muffet/releases/download/v${MUFFET_VERSION}/muffet_linux_amd64.tar.gz | tar -xz @@ -87,6 +97,7 @@ jobs: # twitter.com => too many redirections # www.flaticon.com => 403 error # www.freepik.com => 403 error + # ngrok.com => 406 error - run: | ./muffet \ -e 'https://medium.com/runatlantis' \ @@ -94,6 +105,7 @@ jobs: -e 'https://twitter.com/*' \ -e 'https://www.flaticon.com/*' \ -e 'https://www.freepik.com/*' \ + -e 'https://ngrok.com/*' \ -e 'https://github\.com/runatlantis/atlantis/edit/main/.*' \ -e 'https://github.com/runatlantis/helm-charts#customization' \ -e 'https://github.com/sethvargo/atlantis-on-gke/blob/master/terraform/tls.tf#L64-L84' \ @@ -110,4 +122,9 @@ jobs: name: Website Link Check runs-on: ubuntu-latest steps: + - name: Harden Runner + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + with: + egress-policy: audit + - run: 'echo "No build required"' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..691d45e43b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: +- repo: https://github.com/gitleaks/gitleaks + rev: v8.16.3 + hooks: + - id: gitleaks +- repo: https://github.com/golangci/golangci-lint + rev: v1.52.2 + hooks: + - id: golangci-lint +- repo: https://github.com/jumanjihouse/pre-commit-hooks + rev: 3.0.0 + hooks: + - id: shellcheck +- repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.38.0 + hooks: + - id: eslint +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace diff --git a/Dockerfile b/Dockerfile index 186061acf4..3be802bd29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,23 @@ # syntax=docker/dockerfile:1@sha256:93bfd3b68c109427185cd78b4779fc82b484b0b7618e36d0f104d4d801e66d25 # what distro is the image being built for -ARG ALPINE_TAG=3.21.0@sha256:21dc6063fd678b478f57c0e13f47560d0ea4eeba26dfc947b2a4f81f686b9f45 +ARG ALPINE_TAG=3.21.2@sha256:56fa17d2a7e7f168a043a2712e63aed1f8543aeafdcee47c58dcffe38ed51099 ARG DEBIAN_TAG=12.8-slim@sha256:d365f4920711a9074c4bcd178e8f457ee59250426441ab2a5f8106ed8fe948eb -ARG GOLANG_TAG=1.23.4-alpine@sha256:6c5c9590f169f77c8046e45c611d3b28fe477789acd8d3762d23d4744de69812 +ARG GOLANG_TAG=1.23.5-alpine@sha256:47d337594bd9e667d35514b241569f95fb6d95727c24b19468813d596d5ae596 # renovate: datasource=github-releases depName=hashicorp/terraform versioning=hashicorp -ARG DEFAULT_TERRAFORM_VERSION=1.10.3 +ARG DEFAULT_TERRAFORM_VERSION=1.10.5 # renovate: datasource=github-releases depName=opentofu/opentofu versioning=hashicorp -ARG DEFAULT_OPENTOFU_VERSION=1.8.7 +ARG DEFAULT_OPENTOFU_VERSION=1.8.8 # renovate: datasource=github-releases depName=open-policy-agent/conftest ARG DEFAULT_CONFTEST_VERSION=0.56.0 # Stage 1: build artifact and download deps -FROM golang:${GOLANG_TAG} AS builder +FROM --platform=$BUILDPLATFORM golang:${GOLANG_TAG} AS builder + +# These are automatically populated by Docker +ARG TARGETOS +ARG TARGETARCH ARG ATLANTIS_VERSION=dev ENV ATLANTIS_VERSION=${ATLANTIS_VERSION} @@ -42,7 +46,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ COPY . /app RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X 'main.version=${ATLANTIS_VERSION}' -X 'main.commit=${ATLANTIS_COMMIT}' -X 'main.date=${ATLANTIS_DATE}'" -v -o atlantis . + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags "-s -w -X 'main.version=${ATLANTIS_VERSION}' -X 'main.commit=${ATLANTIS_COMMIT}' -X 'main.date=${ATLANTIS_DATE}'" -v -o atlantis . FROM debian:${DEBIAN_TAG} AS debian-base @@ -94,7 +98,7 @@ RUN AVAILABLE_CONFTEST_VERSIONS=${DEFAULT_CONFTEST_VERSION} && \ # install git-lfs # renovate: datasource=github-releases depName=git-lfs/git-lfs -ENV GIT_LFS_VERSION=3.6.0 +ENV GIT_LFS_VERSION=3.6.1 RUN case ${TARGETPLATFORM} in \ "linux/amd64") GIT_LFS_ARCH=amd64 ;; \ @@ -122,7 +126,7 @@ RUN ./download-release.sh \ "terraform" \ "${TARGETPLATFORM}" \ "${DEFAULT_TERRAFORM_VERSION}" \ - "1.6.6 1.7.5 1.8.5 ${DEFAULT_TERRAFORM_VERSION}" \ + "1.8.5 1.9.8 ${DEFAULT_TERRAFORM_VERSION}" \ && ./download-release.sh \ "tofu" \ "${TARGETPLATFORM}" \ @@ -155,7 +159,7 @@ COPY --from=deps /usr/bin/git-lfs /usr/bin/git-lfs COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # renovate: datasource=repology depName=alpine_3_21/ca-certificates versioning=loose -ENV CA_CERTIFICATES_VERSION="20241010" +ENV CA_CERTIFICATES_VERSION="20241121-r1" # Install packages needed to run Atlantis. # We place this last as it will bust less docker layer caches when packages update diff --git a/cmd/server.go b/cmd/server.go index 5722b38cfa..9c6ade1001 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -72,6 +72,7 @@ const ( CheckoutStrategyFlag = "checkout-strategy" ConfigFlag = "config" DataDirFlag = "data-dir" + DefaultTFDistributionFlag = "default-tf-distribution" DefaultTFVersionFlag = "default-tf-version" DisableApplyAllFlag = "disable-apply-all" DisableAutoplanFlag = "disable-autoplan" @@ -106,6 +107,7 @@ const ( GiteaUserFlag = "gitea-user" GiteaWebhookSecretFlag = "gitea-webhook-secret" // nolint: gosec GiteaPageSizeFlag = "gitea-page-size" + GitlabGroupAllowlistFlag = "gitlab-group-allowlist" GitlabHostnameFlag = "gitlab-hostname" GitlabTokenFlag = "gitlab-token" GitlabUserFlag = "gitlab-user" @@ -141,7 +143,7 @@ const ( SSLCertFileFlag = "ssl-cert-file" SSLKeyFileFlag = "ssl-key-file" RestrictFileList = "restrict-file-list" - TFDistributionFlag = "tf-distribution" + TFDistributionFlag = "tf-distribution" // deprecated for DefaultTFDistributionFlag TFDownloadFlag = "tf-download" TFDownloadURLFlag = "tf-download-url" UseTFPluginCache = "use-tf-plugin-cache" @@ -152,6 +154,7 @@ const ( TFELocalExecutionModeFlag = "tfe-local-execution-mode" TFETokenFlag = "tfe-token" WriteGitCredsFlag = "write-git-creds" // nolint: gosec + WebhookHttpHeaders = "webhook-http-headers" WebBasicAuthFlag = "web-basic-auth" WebUsernameFlag = "web-username" WebPasswordFlag = "web-password" @@ -259,7 +262,7 @@ var stringFlags = map[string]stringFlag{ defaultValue: DefaultBitbucketBaseURL, }, BitbucketWebhookSecretFlag: { - description: "Secret used to validate Bitbucket webhooks. Only Bitbucket Server supports webhook secrets." + + description: "Secret used to validate Bitbucket webhooks." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from Bitbucket. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + "Should be specified via the ATLANTIS_BITBUCKET_WEBHOOK_SECRET environment variable.", @@ -358,6 +361,17 @@ var stringFlags = map[string]stringFlag{ "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + "Should be specified via the ATLANTIS_GITEA_WEBHOOK_SECRET environment variable.", }, + GitlabGroupAllowlistFlag: { + description: "Comma separated list of key-value pairs representing the GitLab groups and the operations that " + + "the members of a particular group are allowed to perform. " + + "The format is {group}:{command},{group}:{command}. " + + "Valid values for 'command' are 'plan', 'apply' and '*', e.g. 'myorg/dev:plan,myorg/ops:apply,myorg/devops:*'" + + "This example gives the users from the 'myorg/dev' GitLab group the permissions to execute the 'plan' command, " + + "the 'myorg/ops' group the permissions to execute the 'apply' command, " + + "and allows the 'myorg/devops' group to perform any operation. If this argument is not provided, the default value (*:*) " + + "will be used and the default behavior will be to not check permissions " + + "and to allow users from any group to perform any operation.", + }, GitlabHostnameFlag: { description: "Hostname of your GitLab Enterprise installation. If using gitlab.com, no need to set.", defaultValue: DefaultGitlabHostname, @@ -421,8 +435,8 @@ var stringFlags = map[string]stringFlag{ description: fmt.Sprintf("File containing x509 private key matching --%s.", SSLCertFileFlag), }, TFDistributionFlag: { - description: fmt.Sprintf("Which TF distribution to use. Can be set to %s or %s.", TFDistributionTerraform, TFDistributionOpenTofu), - defaultValue: DefaultTFDistribution, + description: "[Deprecated for --default-tf-distribution].", + hidden: true, }, TFDownloadURLFlag: { description: "Base URL to download Terraform versions from.", @@ -437,6 +451,10 @@ var stringFlags = map[string]stringFlag{ " Only set if using TFC/E as a remote backend." + " Should be specified via the ATLANTIS_TFE_TOKEN environment variable for security.", }, + DefaultTFDistributionFlag: { + description: fmt.Sprintf("Which TF distribution to use. Can be set to %s or %s.", TFDistributionTerraform, TFDistributionOpenTofu), + defaultValue: DefaultTFDistribution, + }, DefaultTFVersionFlag: { description: "Terraform version to default to (ex. v0.12.0). Will download if not yet on disk." + " If not set, Atlantis uses the terraform binary in its PATH.", @@ -455,6 +473,12 @@ var stringFlags = map[string]stringFlag{ description: "Name used to identify Atlantis for pull request statuses.", defaultValue: DefaultVCSStatusName, }, + WebhookHttpHeaders: { + description: "Additional headers added to each HTTP POST payload when using HTTP webhooks provided as a JSON string." + + " The map key is the header name and the value is the header value (string) or values (array of string)." + + " For example: `{\"Authorization\":\"Bearer some-token\",\"X-Custom-Header\":[\"value1\",\"value2\"]}`.", + defaultValue: "", + }, WebUsernameFlag: { description: "Username used for Web Basic Authentication on Atlantis HTTP Middleware", defaultValue: DefaultWebUsername, @@ -840,12 +864,13 @@ func (s *ServerCmd) run() error { // Config looks good. Start the server. server, err := s.ServerCreator.NewServer(userConfig, server.Config{ - AllowForkPRsFlag: AllowForkPRsFlag, - AtlantisURLFlag: AtlantisURLFlag, - AtlantisVersion: s.AtlantisVersion, - DefaultTFVersionFlag: DefaultTFVersionFlag, - RepoConfigJSONFlag: RepoConfigJSONFlag, - SilenceForkPRErrorsFlag: SilenceForkPRErrorsFlag, + AllowForkPRsFlag: AllowForkPRsFlag, + AtlantisURLFlag: AtlantisURLFlag, + AtlantisVersion: s.AtlantisVersion, + DefaultTFDistributionFlag: DefaultTFDistributionFlag, + DefaultTFVersionFlag: DefaultTFVersionFlag, + RepoConfigJSONFlag: RepoConfigJSONFlag, + SilenceForkPRErrorsFlag: SilenceForkPRErrorsFlag, }) if err != nil { @@ -921,8 +946,11 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig, v *viper.Viper) { if c.RedisPort == 0 { c.RedisPort = DefaultRedisPort } - if c.TFDistribution == "" { - c.TFDistribution = DefaultTFDistribution + if c.TFDistribution != "" && c.DefaultTFDistribution == "" { + c.DefaultTFDistribution = c.TFDistribution + } + if c.DefaultTFDistribution == "" { + c.DefaultTFDistribution = DefaultTFDistribution } if c.TFDownloadURL == "" { c.TFDownloadURL = DefaultTFDownloadURL @@ -953,7 +981,7 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("invalid log level: must be one of %v", ValidLogLevels) } - if userConfig.TFDistribution != TFDistributionTerraform && userConfig.TFDistribution != TFDistributionOpenTofu { + if userConfig.DefaultTFDistribution != TFDistributionTerraform && userConfig.DefaultTFDistribution != TFDistributionOpenTofu { return fmt.Errorf("invalid tf distribution: expected one of %s or %s", TFDistributionTerraform, TFDistributionOpenTofu) } @@ -1006,10 +1034,6 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("--%s cannot contain ://, should be hostnames only", RepoAllowlistFlag) } - if userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && userConfig.BitbucketWebhookSecret != "" { - return fmt.Errorf("--%s cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", BitbucketWebhookSecretFlag) - } - parsed, err := url.Parse(userConfig.BitbucketBaseURL) if err != nil { return fmt.Errorf("error parsing --%s flag value %q: %s", BitbucketWebhookSecretFlag, userConfig.BitbucketBaseURL, err) @@ -1060,6 +1084,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return errors.Wrapf(err, "invalid --%s", AllowCommandsFlag) } + if _, err := userConfig.ToWebhookHttpHeaders(); err != nil { + return errors.Wrapf(err, "invalid --%s", WebhookHttpHeaders) + } + return nil } @@ -1153,9 +1181,6 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL != DefaultBitbucketBaseURL && userConfig.BitbucketWebhookSecret == "" && !s.SilenceOutput { s.Logger.Warn("no Bitbucket webhook secret set. This could allow attackers to spoof requests from Bitbucket") } - if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && !s.SilenceOutput { - s.Logger.Warn("Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are allowing only Bitbucket IPs") - } if userConfig.AzureDevopsWebhookUser != "" && userConfig.AzureDevopsWebhookPassword == "" && !s.SilenceOutput { s.Logger.Warn("no Azure DevOps webhook user and password set. This could allow attackers to spoof requests from Azure DevOps.") } @@ -1172,6 +1197,10 @@ func (s *ServerCmd) deprecationWarnings(userConfig *server.UserConfig) error { // } // + if userConfig.TFDistribution != "" { + deprecatedFlags = append(deprecatedFlags, TFDistributionFlag) + } + if len(deprecatedFlags) > 0 { warning := "WARNING: " if len(deprecatedFlags) == 1 { diff --git a/cmd/server_test.go b/cmd/server_test.go index c14e43cdd6..ea73de2905 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -14,10 +14,13 @@ package cmd import ( + "bufio" + "cmp" "fmt" "os" "path/filepath" "reflect" + "slices" "strings" "testing" @@ -73,6 +76,7 @@ var testFlags = map[string]interface{}{ CheckoutStrategyFlag: CheckoutStrategyMerge, CheckoutDepthFlag: 0, DataDirFlag: "/path", + DefaultTFDistributionFlag: "terraform", DefaultTFVersionFlag: "v0.11.0", DisableApplyAllFlag: true, DisableMarkdownFoldingFlag: true, @@ -100,6 +104,7 @@ var testFlags = map[string]interface{}{ GiteaUserFlag: "gitea-user", GiteaWebhookSecretFlag: "gitea-secret", GiteaPageSizeFlag: 30, + GitlabGroupAllowlistFlag: "", GitlabHostnameFlag: "gitlab-hostname", GitlabTokenFlag: "gitlab-token", GitlabUserFlag: "gitlab-user", @@ -147,6 +152,7 @@ var testFlags = map[string]interface{}{ VarFileAllowlistFlag: "/path", VCSStatusName: "my-status", IgnoreVCSStatusNames: "", + WebhookHttpHeaders: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`, WebBasicAuthFlag: false, WebPasswordFlag: "atlantis", WebUsernameFlag: "atlantis", @@ -225,21 +231,32 @@ func TestExecute_Flags(t *testing.T) { } } -func TestUserConfigAllTested(t *testing.T) { - t.Log("All settings in userConfig should be tested.") - +func getUserConfigKeysWithFlags() []string { + var ret []string u := reflect.TypeOf(server.UserConfig{}) for i := 0; i < u.NumField(); i++ { userConfigKey := u.Field(i).Tag.Get("mapstructure") + // By default, we expect all fields in UserConfig to have flags defined in server.go and tested here in server_test.go + // Some fields are too complicated to have flags, so are only expressible in the config yaml + flagKey := u.Field(i).Tag.Get("flag") + if flagKey == "false" { + continue + } + ret = append(ret, userConfigKey) + + } + return ret + +} + +func TestUserConfigAllTested(t *testing.T) { + t.Log("All settings in userConfig should be tested.") + + for _, userConfigKey := range getUserConfigKeysWithFlags() { + t.Run(userConfigKey, func(t *testing.T) { - // By default, we expect all fields in UserConfig to have flags defined in server.go and tested here in server_test.go - // Some fields are too complicated to have flags, so are only expressible in the config yaml - flagKey := u.Field(i).Tag.Get("flag") - if flagKey == "false" { - return - } // If a setting is configured in server.UserConfig, it should be tested here. If there is no corresponding const // for specifying the flag, that probably means one *also* needs to be added to server.go if _, ok := testFlags[userConfigKey]; !ok { @@ -251,6 +268,94 @@ func TestUserConfigAllTested(t *testing.T) { } +func getDocumentedFlags(t *testing.T) []string { + + var ret []string + docFile := "../runatlantis.io/docs/server-configuration.md" + + file, err := os.Open(docFile) + Ok(t, err) + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "### ") { + continue + } + split := strings.Split(line, "`") + if len(split) != 3 { + t.Errorf("Unexpected line in %s: %s", docFile, line) + continue + } + flag := split[1] + if !strings.HasPrefix(flag, "--") { + t.Errorf("Unexpected line in %s: %s", docFile, line) + continue + } + flag = strings.TrimPrefix(flag, "--") + ret = append(ret, flag) + } + + err = scanner.Err() + Ok(t, err) + + return ret +} + +func testIsSorted[S ~[]E, E cmp.Ordered](t *testing.T, x S) { + // TODO: This is n^2, probably a better algorithm for this + // Also, this works best for lists that are mostly sorted, if the whole thing is wrong, it's just + // going to say that every individual element is out of order + for i, elem := range x { + for j, compareTo := range x { + if i == j { + continue + } + if i > j && cmp.Less(elem, compareTo) { + t.Errorf("%v is out of order (should be before %v)", elem, compareTo) + break + } + if i < j && cmp.Less(compareTo, elem) { + t.Errorf("%v is out of order (should be after %v)", elem, compareTo) + break + } + } + } +} + +func TestAllFlagsDocumented(t *testing.T) { + // This is not a unit test per se, but is a helpful way of making sure when flags are added/removed + // the corresponding documentation is kept up-to-date. + t.Log("All flags in userConfig should have documentation in server-configuration.md.") + + userConfigKeys := getUserConfigKeysWithFlags() + documentedFlags := getDocumentedFlags(t) + + testIsSorted(t, documentedFlags) + slices.Sort(userConfigKeys) + slices.Sort(documentedFlags) + + for _, userConfigKey := range userConfigKeys { + _, found := slices.BinarySearch(documentedFlags, userConfigKey) + if !found { + t.Errorf("Found undocumented config key: %s", userConfigKey) + } + } + + for _, documentedFlag := range documentedFlags { + // --help and --config are documented but don't have a setting on userConfig + if documentedFlag == "help" || documentedFlag == "config" { + continue + } + _, found := slices.BinarySearch(userConfigKeys, documentedFlag) + if !found { + t.Errorf("Found documentation for flag that doesn't exist: %s", documentedFlag) + } + } + +} + func TestExecute_ConfigFile(t *testing.T) { t.Log("Should use all the values from the config file.") // Use yaml package to quote values that need quoting @@ -835,18 +940,6 @@ func TestExecute_ADUser(t *testing.T) { Equals(t, "user", passedConfig.AzureDevopsUser) } -// If using bitbucket cloud, webhook secrets are not supported. -func TestExecute_BitbucketCloudWithWebhookSecret(t *testing.T) { - c := setup(map[string]interface{}{ - BitbucketUserFlag: "user", - BitbucketTokenFlag: "token", - RepoAllowlistFlag: "*", - BitbucketWebhookSecretFlag: "my secret", - }, t) - err := c.Execute() - ErrEquals(t, "--bitbucket-webhook-secret cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", err) -} - // Base URL must have a scheme. func TestExecute_BitbucketServerBaseURLScheme(t *testing.T) { c := setup(map[string]interface{}{ @@ -977,6 +1070,46 @@ func TestExecute_AutoplanFileList(t *testing.T) { } } +func TestExecute_ValidateDefaultTFDistribution(t *testing.T) { + cases := []struct { + description string + flags map[string]interface{} + expectErr string + }{ + { + "terraform", + map[string]interface{}{ + DefaultTFDistributionFlag: "terraform", + }, + "", + }, + { + "opentofu", + map[string]interface{}{ + DefaultTFDistributionFlag: "opentofu", + }, + "", + }, + { + "errs on invalid distribution", + map[string]interface{}{ + DefaultTFDistributionFlag: "invalid_distribution", + }, + "invalid tf distribution: expected one of terraform or opentofu", + }, + } + for _, testCase := range cases { + t.Log("Should validate default tf distribution when " + testCase.description) + c := setupWithDefaults(testCase.flags, t) + err := c.Execute() + if testCase.expectErr != "" { + ErrEquals(t, testCase.expectErr, err) + } else { + Ok(t, err) + } + } +} + func setup(flags map[string]interface{}, t *testing.T) *cobra.Command { vipr := viper.New() for k, v := range flags { diff --git a/docker-compose.yml b/docker-compose.yml index 2fc7fd4a9c..a9342ab364 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: depends_on: - atlantis redis: - image: redis:7.4-alpine@sha256:c1e88455c85225310bbea54816e9c3f4b5295815e6dbf80c34d40afc6df28275 + image: redis:7.4-alpine@sha256:1bf97f21f01b0e7bd4b7b34a26d3b9d8086e41e70c10f262e8a9e0b49b5116a0 restart: always ports: - 6379:6379 diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000000..fcaec9ad74 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,15 @@ +# End to end tests + +Tests run against actual repos in various VCS providers + +## Configuration + +### Gitlab + +User: https://gitlab.com/atlantis-tests +Email: maintainers@runatlantis.io + +To rotate token: +1. Login to account +2. Select avatar -> Edit Profile -> Access tokens -> Add new token +3. Create a new token, and upload it to Github Action as environment secret `ATLANTIS_GITLAB_TOKEN`. diff --git a/e2e/github.go b/e2e/github.go index 5037b52e87..b86145d846 100644 --- a/e2e/github.go +++ b/e2e/github.go @@ -21,7 +21,7 @@ import ( "os/exec" "strings" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" ) type GithubClient struct { @@ -90,7 +90,7 @@ func (g GithubClient) CreateAtlantisWebhook(ctx context.Context, hookURL string) atlantisHook := &github.Hook{ Events: []string{"issue_comment", "pull_request", "push"}, Config: hookConfig, - Active: github.Bool(true), + Active: github.Ptr(true), } hook, _, err := g.client.Repositories.CreateHook(ctx, g.ownerName, g.repoName, atlantisHook) @@ -146,7 +146,7 @@ func (g GithubClient) GetAtlantisStatus(ctx context.Context, branchName string) func (g GithubClient) ClosePullRequest(ctx context.Context, pullRequestNumber int) error { // clean up - _, _, err := g.client.PullRequests.Edit(ctx, g.ownerName, g.repoName, pullRequestNumber, &github.PullRequest{State: github.String("closed")}) + _, _, err := g.client.PullRequests.Edit(ctx, g.ownerName, g.repoName, pullRequestNumber, &github.PullRequest{State: github.Ptr("closed")}) if err != nil { return fmt.Errorf("error while closing new pull request: %v", err) } diff --git a/e2e/gitlab.go b/e2e/gitlab.go index 2226aa299d..a8f6449f49 100644 --- a/e2e/gitlab.go +++ b/e2e/gitlab.go @@ -20,7 +20,7 @@ import ( "os" "os/exec" - "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) type GitlabClient struct { diff --git a/e2e/go.mod b/e2e/go.mod index 1d706f3f8a..ba7f5a24a5 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -1,22 +1,22 @@ module github.com/runatlantis/atlantis/e2e -go 1.23.4 +go 1.23.5 require ( - github.com/google/go-github/v66 v66.0.0 + github.com/google/go-github/v68 v68.0.0 github.com/hashicorp/go-multierror v1.1.1 - github.com/xanzy/go-gitlab v0.114.0 + gitlab.com/gitlab-org/api/client-go v0.118.0 ) require ( - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/sys v0.28.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.36.0 // indirect ) diff --git a/e2e/go.sum b/e2e/go.sum index 17d5056f99..825bce8d88 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -2,16 +2,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= -github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= +github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -31,30 +31,46 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/xanzy/go-gitlab v0.114.0 h1:0wQr/KBckwrZPfEMjRqpUz0HmsKKON9UhCYv9KDy19M= -github.com/xanzy/go-gitlab v0.114.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/gitlab-org/api/client-go v0.118.0 h1:qHIEw+XHt+2xuk4iZGW8fc6t+gTLAGEmTA5Bzp/brxs= +gitlab.com/gitlab-org/api/client-go v0.118.0/go.mod h1:E+X2dndIYDuUfKVP0C3jhkWvTSE00BkLbCsXTY3edDo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= +google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.mod b/go.mod index 0328202cbd..03730a8f60 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,21 @@ module github.com/runatlantis/atlantis -go 1.23.4 +go 1.23.5 require ( code.gitea.io/sdk/gitea v0.19.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/alicebob/miniredis/v2 v2.34.0 - github.com/bradleyfalzon/ghinstallation/v2 v2.12.0 + github.com/bmatcuk/doublestar/v4 v4.8.0 + github.com/bradleyfalzon/ghinstallation/v2 v2.13.0 github.com/briandowns/spinner v1.23.1 github.com/cactus/go-statsd-client/v5 v5.1.0 github.com/go-ozzo/ozzo-validation v3.6.0+incompatible github.com/go-playground/validator/v10 v10.23.0 github.com/go-test/deep v1.1.1 + github.com/gofri/go-github-ratelimit v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/google/go-github/v66 v66.0.0 + github.com/google/go-github/v68 v68.0.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 @@ -45,7 +47,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/uber-go/tally/v4 v4.1.16 github.com/urfave/negroni/v3 v3.1.1 - github.com/xanzy/go-gitlab v0.114.0 + gitlab.com/gitlab-org/api/client-go v0.118.0 go.etcd.io/bbolt v1.3.11 go.uber.org/zap v1.27.0 golang.org/x/term v0.27.0 @@ -88,7 +90,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang/mock v1.6.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -140,6 +142,6 @@ require ( golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.36.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 5e21b61577..699c99f807 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= -github.com/bradleyfalzon/ghinstallation/v2 v2.12.0 h1:k8oVjGhZel2qmCUsYwSE34jPNT9DL2wCBOtugsHv26g= -github.com/bradleyfalzon/ghinstallation/v2 v2.12.0/go.mod h1:V4gJcNyAftH0rXpRp1SUVUuh+ACxOH1xOk/ZzkRHltg= +github.com/bmatcuk/doublestar/v4 v4.8.0 h1:DSXtrypQddoug1459viM9X9D3dp1Z7993fw36I2kNcQ= +github.com/bmatcuk/doublestar/v4 v4.8.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bradleyfalzon/ghinstallation/v2 v2.13.0 h1:5FhjW93/YLQJDmPdeyMPw7IjAPzqsr+0jHPfrPz0sZI= +github.com/bradleyfalzon/ghinstallation/v2 v2.13.0/go.mod h1:EJ6fgedVEHa2kUyBTTvslJCXJafS/mhJNNKEOCspZXQ= github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -163,6 +165,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4 github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gofri/go-github-ratelimit v1.1.0 h1:ijQ2bcv5pjZXNil5FiwglCg8wc9s8EgjTmNkqjw8nuk= +github.com/gofri/go-github-ratelimit v1.1.0/go.mod h1:OnCi5gV+hAG/LMR7llGhU7yHt44se9sYgKPnafoL7RY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -199,8 +203,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -216,8 +220,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= -github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= +github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= @@ -461,8 +465,6 @@ github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= -github.com/xanzy/go-gitlab v0.114.0 h1:0wQr/KBckwrZPfEMjRqpUz0HmsKKON9UhCYv9KDy19M= -github.com/xanzy/go-gitlab v0.114.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -476,6 +478,8 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8 github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +gitlab.com/gitlab-org/api/client-go v0.118.0 h1:qHIEw+XHt+2xuk4iZGW8fc6t+gTLAGEmTA5Bzp/brxs= +gitlab.com/gitlab-org/api/client-go v0.118.0/go.mod h1:E+X2dndIYDuUfKVP0C3jhkWvTSE00BkLbCsXTY3edDo= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -806,8 +810,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= +google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/package-lock.json b/package-lock.json index 01ce075145..1a3739258b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "markdownlint-cli": "^0.40.0", "mermaid": "^10.9.1", "sitemap-ts": "^1.7.3", + "vite": "^5.4.12", "vitepress": "^1.2.3", "vitepress-plugin-mermaid": "^2.0.16", "vue": "^3.4.27" @@ -3070,15 +3071,14 @@ } }, "node_modules/katex": { - "version": "0.16.15", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.15.tgz", - "integrity": "sha512-yE9YJIEAk2aZ+FL/G8r+UGw0CTUzEA8ZFy6E+8tc3spHUKq3qBnzCkI1CQwGoI9atJhVyFPEypQsTY7mJ1Pi9w==", + "version": "0.16.21", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz", + "integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==", "dev": true, "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], - "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -4904,9 +4904,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "5.4.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.12.tgz", + "integrity": "sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index bdd8fe208c..4b9915e265 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "markdownlint-cli": "^0.40.0", "mermaid": "^10.9.1", "sitemap-ts": "^1.7.3", + "vite": "^5.4.12", "vitepress": "^1.2.3", "vitepress-plugin-mermaid": "^2.0.16", "vue": "^3.4.27" diff --git a/runatlantis.io/.vitepress/sidebars.ts b/runatlantis.io/.vitepress/sidebars.ts index 5bcabfc4bc..9afc20f780 100644 --- a/runatlantis.io/.vitepress/sidebars.ts +++ b/runatlantis.io/.vitepress/sidebars.ts @@ -44,7 +44,7 @@ const en = [ { text: "Checkout Strategy", link: "/docs/checkout-strategy" }, { text: "Terraform Versions", link: "/docs/terraform-versions" }, { text: "Terraform Cloud", link: "/docs/terraform-cloud" }, - { text: "Using Slack Hooks", link: "/docs/using-slack-hooks" }, + { text: "Sending Notifications via Webhooks", link: "/docs/sending-notifications-via-webhooks" }, { text: "Stats", link: "/docs/stats" }, { text: "FAQ", link: "/docs/faq" }, ] diff --git a/runatlantis.io/docs/access-credentials.md b/runatlantis.io/docs/access-credentials.md index d7b76573ce..34023d5fdd 100644 --- a/runatlantis.io/docs/access-credentials.md +++ b/runatlantis.io/docs/access-credentials.md @@ -132,7 +132,7 @@ A new permission for `Actions` has been added, which is required for checking if * Create an App Password by following [BitBucket Cloud: Create an app password](https://support.atlassian.com/bitbucket-cloud/docs/create-an-app-password/) * Label the password "atlantis" -* Select **Pull requests**: **Read** and **Write** so that Atlantis can read your pull requests and write comments to them +* Select **Pull requests**: **Read** and **Write** so that Atlantis can read your pull requests and write comments to them. If you want to enable the [hide-prev-plan-comments](./server-configuration#hide-prev-plan-comments) feature and thus delete old comments, please add **Account**: **Read** as well. * Record the access token ### Bitbucket Server (aka Stash) diff --git a/runatlantis.io/docs/deployment.md b/runatlantis.io/docs/deployment.md index dfe5ae27cc..5df948bc64 100644 --- a/runatlantis.io/docs/deployment.md +++ b/runatlantis.io/docs/deployment.md @@ -117,10 +117,6 @@ echo -n "yoursecret" > webhook-secret kubectl create secret generic atlantis-vcs --from-file=token --from-file=webhook-secret ``` -::: tip Note -If you're using Bitbucket Cloud then there is no webhook secret since it's not supported. -::: - Next, edit the manifests below as follows: 1. Replace `` in `image: ghcr.io/runatlantis/atlantis:` with the most recent version from [GitHub: Atlantis latest release](https://github.com/runatlantis/atlantis/releases/latest). @@ -231,6 +227,11 @@ spec: secretKeyRef: name: atlantis-vcs key: token + - name: ATLANTIS_BITBUCKET_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: webhook-secret ### End Bitbucket Config ### ### Azure DevOps Config ### @@ -742,6 +743,7 @@ atlantis server \ --atlantis-url="$URL" \ --bitbucket-user="$USERNAME" \ --bitbucket-token="$TOKEN" \ +--bitbucket-webhook-secret="$SECRET" \ --repo-allowlist="$REPO_ALLOWLIST" ``` diff --git a/runatlantis.io/docs/repo-level-atlantis-yaml.md b/runatlantis.io/docs/repo-level-atlantis-yaml.md index a5e89d20a4..da7defd51e 100644 --- a/runatlantis.io/docs/repo-level-atlantis-yaml.md +++ b/runatlantis.io/docs/repo-level-atlantis-yaml.md @@ -57,6 +57,8 @@ version: 3 automerge: true autodiscover: mode: auto + ignore_paths: + - some/path delete_source_branch_on_merge: true parallel_plan: true parallel_apply: true @@ -66,6 +68,7 @@ projects: branch: /main/ dir: . workspace: default + terraform_distribution: terraform terraform_version: v0.11.0 delete_source_branch_on_merge: true repo_locking: true # deprecated: use repo_locks instead @@ -262,6 +265,20 @@ See [Custom Workflow Use Cases: Terragrunt](custom-workflows.md#terragrunt) See [Custom Workflow Use Cases: Running custom commands](custom-workflows.md#running-custom-commands) +### Terraform Distributions + +If you'd like to use a different distribution of Terraform than what is set +by the `--default-tf-version` flag, then set the `terraform_distribution` key: + +```yaml +version: 3 +projects: +- dir: project1 + terraform_distribution: opentofu +``` + +Atlantis will automatically download and use this distribution. Valid values are `terraform` and `opentofu`. + ### Terraform Versions If you'd like to use a different version of Terraform than what is in Atlantis' @@ -390,6 +407,15 @@ the manual configuration will take precedence. Use this feature when some projects require specific configuration in a repo with many projects yet it's still desirable for Atlantis to plan/apply for projects not enumerated in the config. +```yaml +autodiscover: + mode: "enabled" + ignore_paths: + - dir/* +``` + +Autodiscover can also be configured to skip over directories that match a path glob (as defined [here](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4)) + ### Custom Backend Config See [Custom Workflow Use Cases: Custom Backend Config](custom-workflows.md#custom-backend-config) diff --git a/runatlantis.io/docs/security.md b/runatlantis.io/docs/security.md index 0f5d8df4c6..daa2bdfecb 100644 --- a/runatlantis.io/docs/security.md +++ b/runatlantis.io/docs/security.md @@ -22,21 +22,6 @@ Atlantis could be exploited by * Running malicious custom build commands specified in an `atlantis.yaml` file. Atlantis uses the `atlantis.yaml` file from the pull request branch, **not** `main`. * Someone adding `atlantis plan/apply` comments on your valid pull requests causing terraform to run when you don't want it to. -## Bitbucket Cloud (bitbucket.org) - -::: danger -Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are allowing only Bitbucket IPs. -::: -Bitbucket Cloud doesn't support webhook secrets. This means that an attacker could -make fake requests to Atlantis that look like they're coming from Bitbucket. - -If you are specifying `--repo-allowlist` then they could only fake requests pertaining -to those repos so the most damage they could do would be to plan/apply on your -own repos. - -To prevent this, allowlist [Bitbucket's IP addresses](https://confluence.atlassian.com/bitbucket/what-are-the-bitbucket-cloud-ip-addresses-i-should-use-to-configure-my-corporate-firewall-343343385.html) - (see Outbound IPv4 addresses). - ## Mitigations ### Don't Use On Public Repos diff --git a/runatlantis.io/docs/sending-notifications-via-webhooks.md b/runatlantis.io/docs/sending-notifications-via-webhooks.md new file mode 100644 index 0000000000..9272a25c89 --- /dev/null +++ b/runatlantis.io/docs/sending-notifications-via-webhooks.md @@ -0,0 +1,151 @@ +# Sending notifications via webhooks + +It is possible to send notifications to external systems whenever an apply is being done. + +You can make requests to any HTTP endpoint or send messages directly to your Slack channel. + +::: tip NOTE +Currently only `apply` events are supported. +::: + +## Configuration + +Webhooks are configured in Atlantis [server-side configuration](server-configuration.md). +There can be many webhooks: sending notifications to different destinations or for different +workspaces/branches. Here is example configuration to send Slack messages for every apply: + +```yaml +webhooks: +- event: apply + kind: slack + channel: my-channel-id +``` + +If you are deploying Atlantis as a Helm chart, this can be implemented via the `config` parameter available for [chart customizations](https://github.com/runatlantis/helm-charts#customization): + +```yaml +## Use Server Side Config, +## ref: https://www.runatlantis.io/docs/server-configuration.html +config: | + --- + webhooks: + - event: apply + kind: slack + channel: my-channel-id +``` + +### Filter on workspace/branch + +To limit notifications to particular workspaces or branches, use `workspace-regex` or `branch-regex` parameters. +If the workspace **and** branch matches respective regex, an event will be sent. Note that empty regular expression +(a result of unset parameter) matches every string. + +## Using HTTP webhooks + +You can send POST requests with JSON payload to any HTTP/HTTPS server. + +### Configuring Atlantis + +In your Atlantis [server-side configuration](server-configuration.md) you can add the following: + +```yaml +webhooks: +- event: apply + kind: http + url: https://example.com/hooks +``` + +The `apply` event information will be POSTed to `https://example.com/hooks`. + +You can supply any additional headers with `--webhook-http-headers` parameter (or environment variable), +for example for authentication purposes. See [webhook-http-headers](server-configuration.md#webhook-http-headers) for details. + +### JSON payload + +The payload is a JSON-marshalled [ApplyResult](https://pkg.go.dev/github.com/runatlantis/atlantis/server/events/webhooks#ApplyResult) struct. + +Example payload: + +```json +{ + "Workspace": "default", + "Repo": { + "FullName": "octocat/Hello-World", + "Owner": "octocat", + "Name": "Hello-World", + "CloneURL": "https://:@github.com/octocat/Hello-World.git", + "SanitizedCloneURL": "https://:@github.com/octocat/Hello-World.git", + "VCSHost": { + "Hostname": "github.com", + "Type": 0 + } + }, + "Pull": { + "Num": 2137, + "HeadCommit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", + "URL": "https://github.com/octocat/Hello-World/pull/2137", + "HeadBranch": "feature/some-branch", + "BaseBranch": "main", + "Author": "octocat", + "State": 0, + "BaseRepo": { + "FullName": "octocat/Hello-World", + "Owner": "octocat", + "Name": "Hello-World", + "CloneURL": "https://:@github.com/octocat/Hello-World.git", + "SanitizedCloneURL": "https://:@github.com/octocat/Hello-World.git", + "VCSHost": { + "Hostname": "github.com", + "Type": 0 + } + } + }, + "User": { + "Username": "octocat", + "Teams": null + }, + "Success": true, + "Directory": "terraform/example", + "ProjectName": "example-project" +} +``` + +## Using Slack hooks + +For this you'll need to: + +* Create a Bot user in Slack +* Configure Atlantis to send notifications to Slack. + +### Configuring Slack for Atlantis + +* Go to [Slack: Apps](https://api.slack.com/apps) +* Click the `Create New App` button +* Select `From scratch` in the dialog that opens +* Give it a name, e.g. `atlantis-bot`. +* Select your Slack workspace +* Click `Create App` +* On the left go to `oAuth & Permissions` +* Scroll down to Scopes | Bot Token Scopes and add the following OAuth scopes: + * `channels:read` + * `chat:write` + * `groups:read` + * `incoming-webhook` + * `mpim:read` +* Install the app onto your Slack workspace +* Copy the `Bot User OAuth Token` and provide it to Atlantis by using `--slack-token=xoxb-xxxxxxxxxxx` or via the environment `ATLANTIS_SLACK_TOKEN=xoxb-xxxxxxxxxxx`. +* Create a channel in your Slack workspace (e.g. `my-channel`) or use existing +* Add the app to Created channel or existing channel ( click channel name then tab integrations, there Click "Add apps" + +### Configuring Atlantis + +After following the above steps it is time to configure Atlantis. Assuming you have already provided the `slack-token` (via parameter or environment variable) you can now instruct Atlantis to send `apply` events to Slack. + +In your Atlantis [server-side configuration](server-configuration.md) you can now add the following: + +```yaml +webhooks: +- event: apply + kind: slack + channel: my-channel-id +``` diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 54d53f0d60..352696cb42 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -330,8 +330,7 @@ and set `--autoplan-modules` to `false`. ATLANTIS_BITBUCKET_WEBHOOK_SECRET="secret" ``` - Secret used to validate Bitbucket webhooks. Only Bitbucket Server supports webhook secrets. - For Bitbucket.org, see [Security](security.md#bitbucket-cloud-bitbucket-org) for mitigations. + Secret used to validate Bitbucket webhooks. ::: warning SECURITY WARNING If not specified, Atlantis won't be able to validate that the incoming webhook call came from Bitbucket. @@ -386,6 +385,16 @@ and set `--autoplan-modules` to `false`. Note that the atlantis user is restricted to `~/.atlantis`. If you set the `--data-dir` flag to a path outside of Atlantis its home directory, ensure that you grant the atlantis user the correct permissions. +### `--default-tf-distribution` + + ```bash + atlantis server --default-tf-distribution="terraform" + # or + ATLANTIS_DEFAULT_TF_DISTRIBUTION="terraform" + ``` + + Which TF distribution to use. Can be set to `terraform` or `opentofu`. + ### `--default-tf-version` ```bash @@ -430,6 +439,16 @@ and set `--autoplan-modules` to `false`. If `disable-autoplan` property is `true`, this flag has no effect. +### `--disable-global-apply-lock` + + ```bash + atlantis server --disable-global-apply-lock + # or + ATLANTIS_DISABLE_GLOBAL_APPLY_LOCK=true + ``` + + If true, removes button in the UI that allows users to globally disable apply commands. + ### `--disable-markdown-folding` ```bash @@ -460,6 +479,16 @@ and set `--autoplan-modules` to `false`. Stops atlantis from unlocking a pull request with this label. Defaults to "" (feature disabled). +### `--discard-approval-on-plan` + + ```bash + atlantis server --discard-approval-on-plan + # or + ATLANTIS_DISCARD_APPROVAL_ON_PLAN=true + ``` + + If set, discard approval if a new plan has been executed. Currently only supported in Github. + ### `--emoji-reaction` ```bash @@ -538,66 +567,6 @@ and set `--autoplan-modules` to `false`. Fail and do not run the requested Atlantis command if any of the pre workflow hooks error. -### `--gitea-base-url` - - ```bash - atlantis server --gitea-base-url="http://your-gitea.corp:7990/basepath" - # or - ATLANTIS_GITEA_BASE_URL="http://your-gitea.corp:7990/basepath" - ``` - - Base URL of Gitea installation. Must include `http://` or `https://`. Defaults to `https://gitea.com` if left empty/absent. - -### `--gitea-token` - - ```bash - atlantis server --gitea-token="token" - # or (recommended) - ATLANTIS_GITEA_TOKEN="token" - ``` - - Gitea app password of API user. - -### `--gitea-user` - - ```bash - atlantis server --gitea-user="myuser" - # or - ATLANTIS_GITEA_USER="myuser" - ``` - - Gitea username of API user. - -### `--gitea-webhook-secret` - - ```bash - atlantis server --gitea-webhook-secret="secret" - # or (recommended) - ATLANTIS_GITEA_WEBHOOK_SECRET="secret" - ``` - - Secret used to validate Gitea webhooks. - - ::: warning SECURITY WARNING - If not specified, Atlantis won't be able to validate that the incoming webhook call came from Gitea. - This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. - ::: - -### `--gitea-page-size` - - ```bash - atlantis server --gitea-page-size=30 - # or (recommended) - ATLANTIS_GITEA_PAGE_SIZE=30 - ``` - - Number of items on a single page in Gitea paged responses. - - ::: warning Configuration dependent - The default value conforms to the Gitea server's standard config setting: DEFAULT_PAGING_NUM - The highest valid value depends on the Gitea server's config setting: MAX_RESPONSE_ITEMS - ::: - ### `--gh-allow-mergeable-bypass-apply` ```bash @@ -634,6 +603,21 @@ and set `--autoplan-modules` to `false`. After which Atlantis will display your new app's credentials: your app's ID, its generated `--gh-webhook-secret` and the contents of the file for `--gh-app-key-file`. Update your Atlantis config accordingly, and restart the server. ::: +### `--gh-app-installation-id` + + ```bash + atlantis server --gh-app-installation-id="123" + # or + ATLANTIS_GH_APP_INSTALLATION_ID="123" + ``` + +The installation ID of a specific instance of a GitHub application. Normally this value is +derived by querying GitHub for the list of installations of the ID supplied via `--gh-app-id` and selecting +the first one found and where multiple installations results in an error. Use this flag if you have multiple +instances of Atlantis but you want to use a single already-installed GitHub app for all of them. You would normally do this if +you are running a proxy as your single GitHub application that will proxy to an appropriate Atlantis instance +based on the organization or user that triggered the webhook. + ### `--gh-app-key` ```bash @@ -679,21 +663,6 @@ and set `--autoplan-modules` to `false`. Hostname of your GitHub Enterprise installation. If using [GitHub.com](https://github.com), don't set. Defaults to `github.com`. -### `--gh-app-installation-id` - - ```bash - atlantis server --gh-app-installation-id="123" - # or - ATLANTIS_GH_APP_INSTALLATION_ID="123" - ``` - -The installation ID of a specific instance of a GitHub application. Normally this value is -derived by querying GitHub for the list of installations of the ID supplied via `--gh-app-id` and selecting -the first one found and where multiple installations results in an error. Use this flag if you have multiple -instances of Atlantis but you want to use a single already-installed GitHub app for all of them. You would normally do this if -you are running a proxy as your single GitHub application that will proxy to an appropriate Atlantis instance -based on the organization or user that triggered the webhook. - ### `--gh-org` ```bash @@ -753,7 +722,7 @@ based on the organization or user that triggered the webhook. ATLANTIS_GH_USER="myuser" ``` - GitHub username of API user. + GitHub username of API user. This user is also used by the flag `--hide-user-plan-comments` and will need to be updated if migrating to github EMU. ### `--gh-webhook-secret` @@ -770,6 +739,81 @@ based on the organization or user that triggered the webhook. This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. ::: +### `--gitea-base-url` + + ```bash + atlantis server --gitea-base-url="http://your-gitea.corp:7990/basepath" + # or + ATLANTIS_GITEA_BASE_URL="http://your-gitea.corp:7990/basepath" + ``` + + Base URL of Gitea installation. Must include `http://` or `https://`. Defaults to `https://gitea.com` if left empty/absent. + +### `--gitea-page-size` + + ```bash + atlantis server --gitea-page-size=30 + # or (recommended) + ATLANTIS_GITEA_PAGE_SIZE=30 + ``` + + Number of items on a single page in Gitea paged responses. + + ::: warning Configuration dependent + The default value conforms to the Gitea server's standard config setting: DEFAULT_PAGING_NUM + The highest valid value depends on the Gitea server's config setting: MAX_RESPONSE_ITEMS + ::: + +### `--gitea-token` + + ```bash + atlantis server --gitea-token="token" + # or (recommended) + ATLANTIS_GITEA_TOKEN="token" + ``` + + Gitea app password of API user. + +### `--gitea-user` + + ```bash + atlantis server --gitea-user="myuser" + # or + ATLANTIS_GITEA_USER="myuser" + ``` + + Gitea username of API user. + +### `--gitea-webhook-secret` + + ```bash + atlantis server --gitea-webhook-secret="secret" + # or (recommended) + ATLANTIS_GITEA_WEBHOOK_SECRET="secret" + ``` + + Secret used to validate Gitea webhooks. + + ::: warning SECURITY WARNING + If not specified, Atlantis won't be able to validate that the incoming webhook call came from Gitea. + This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. + ::: + +### `--gitlab-group-allowlist` + + ```bash + atlantis server --gitlab-group-allowlist="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import" + # or + ATLANTIS_GITLAB_GROUP_ALLOWLIST="myorg/mygroup:plan, myorg/secteam:apply, myorg/devops:apply, myorg/devops:import" + ``` + + Comma-separated list of GitLab groups and permission pairs. + + By default, any group can plan and apply. + + ::: warning NOTE + Atlantis needs to be able to view the listed group members, inaccessible or non-existent groups are silently ignored. + ### `--gitlab-hostname` ```bash @@ -833,8 +877,14 @@ based on the organization or user that triggered the webhook. ``` Hide previous plan comments to declutter PRs. This is only supported in - GitHub and GitLab currently. This is not enabled by default. When using Github App, you need to set `--gh-app-slug` to enable this feature. - + GitHub and GitLab and Bitbucket currently and is not enabled by default. + + For Bitbucket, the comments are deleted rather than hidden as Bitbucket does not support hiding comments. + + For GitHub, ensure the `--gh-user` is set appropriately or comments will not be hidden. + + When using the GitHub App, you need to set `--gh-app-slug` to enable this feature. + ### `--hide-unchanged-plan-comments` ```bash @@ -847,18 +897,6 @@ Remove no-changes plan comments from the pull request. This is useful when you have many projects and want to keep the pull request clean from useless comments. -### `--include-git-untracked-files` - - ```bash - atlantis server --include-git-untracked-files - # or - ATLANTIS_INCLUDE_GIT_UNTRACKED_FILES=true - ``` - - Include git untracked files in the Atlantis modified file list. - Used for example with CDKTF pre-workflow hooks that dynamically generate - Terraform files. - ### `--ignore-vcs-status-names` ```bash @@ -873,6 +911,18 @@ This is useful when you have many projects and want to keep the pull request cle from other Atlantis services when checking if the PR is mergeable. Currently only implemented for GitHub. +### `--include-git-untracked-files` + + ```bash + atlantis server --include-git-untracked-files + # or + ATLANTIS_INCLUDE_GIT_UNTRACKED_FILES=true + ``` + + Include git untracked files in the Atlantis modified file list. + Used for example with CDKTF pre-workflow hooks that dynamically generate + Terraform files. + ### `--locking-db-type` ```bash @@ -1223,7 +1273,7 @@ This is useful when you have many projects and want to keep the pull request cle ATLANTIS_SLACK_TOKEN='token' ``` - API token for Slack notifications. See [Using Slack hooks](using-slack-hooks.md). + API token for Slack notifications. See [Using Slack hooks](sending-notifications-via-webhooks.md#using-slack-hooks). ### `--ssl-cert-file` @@ -1259,13 +1309,8 @@ This is useful when you have many projects and want to keep the pull request cle ### `--tf-distribution` - ```bash - atlantis server --tf-distribution="terraform" - # or - ATLANTIS_TF_DISTRIBUTION="terraform" - ``` - - Which TF distribution to use. Can be set to `terraform` or `opentofu`. + + Deprecated for `--default-tf-distribution`. ### `--tf-download` @@ -1399,6 +1444,18 @@ The effect of the race condition is more evident when using parallel configurati Username used for Basic Authentication on the Atlantis web service. Defaults to `atlantis`. +### `--webhook-http-headers` + + ```bash + atlantis server --webhook-http-headers='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}' + # or + ATLANTIS_WEBHOOK_HTTP_HEADERS='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}' + ``` + + Additional headers added to each HTTP POST payload when using [http webhooks](sending-notifications-via-webhooks.md#using-http-webhooks) + provided as a JSON string. The map key is the header name and the value is the header value + (string) or values (array of string). + ### `--websocket-check-origin` ```bash diff --git a/runatlantis.io/docs/server-side-repo-config.md b/runatlantis.io/docs/server-side-repo-config.md index 2469eec4d7..892466a747 100644 --- a/runatlantis.io/docs/server-side-repo-config.md +++ b/runatlantis.io/docs/server-side-repo-config.md @@ -99,6 +99,9 @@ repos: # autodiscover defines how atlantis should automatically discover projects in this repository. autodiscover: mode: auto + # Optionally ignore some paths for autodiscovery by a glob path + ignore_paths: + - foo/* # id can also be an exact match. - id: github.com/myorg/specific-repo diff --git a/runatlantis.io/docs/using-slack-hooks.md b/runatlantis.io/docs/using-slack-hooks.md deleted file mode 100644 index 572b0857f8..0000000000 --- a/runatlantis.io/docs/using-slack-hooks.md +++ /dev/null @@ -1,64 +0,0 @@ -# Using Slack hooks - -It is possible to use Slack to send notifications to your Slack channel whenever an apply is being done. - -::: tip NOTE -Currently only `apply` events are supported. -::: - -For this you'll need to: - -* Create a Bot user in Slack -* Configure Atlantis to send notifications to Slack. - -## Configuring Slack for Atlantis - -* Go to [Slack: Apps](https://api.slack.com/apps) -* Click the `Create New App` button -* Select `From scratch` in the dialog that opens -* Give it a name, e.g. `atlantis-bot`. -* Select your Slack workspace -* Click `Create App` -* On the left go to `oAuth & Permissions` -* Scroll down to Scopes | Bot Token Scopes and add the following OAuth scopes: - * `channels:read` - * `chat:write` - * `groups:read` - * `incoming-webhook` - * `mpim:read` -* Install the app onto your Slack workspace -* Copy the `Bot User OAuth Token` and provide it to Atlantis by using `--slack-token=xoxb-xxxxxxxxxxx` or via the environment `ATLANTIS_SLACK_TOKEN=xoxb-xxxxxxxxxxx`. -* Create a channel in your Slack workspace (e.g. `my-channel`) or use existing -* Add the app to Created channel or existing channel ( click channel name then tab integrations, there Click "Add apps" - -## Configuring Atlantis - -After following the above steps it is time to configure Atlantis. Assuming you have already provided the `slack-token` (via parameter or environment variable) you can now instruct Atlantis to send `apply` events to Slack. - -In your Atlantis configuration you can now add the following: - -```yaml -webhooks: -- event: apply - workspace-regex: .* - branch-regex: .* - kind: slack - channel: my-channel-id -``` - -If you are deploying Atlantis as a Helm chart, this can be implemented via the `config` parameter available for [chart customizations](https://github.com/runatlantis/helm-charts#customization): - -```yaml -## Use Server Side Config, -## ref: https://www.runatlantis.io/docs/server-configuration.html -config: | - --- - webhooks: - - event: apply - workspace-regex: .* - branch-regex: .* - kind: slack - channel: my-channel-id -``` - -The `apply` event information will be sent to the `my-channel-id` Slack channel. diff --git a/runatlantis.io/docs/webhook-secrets.md b/runatlantis.io/docs/webhook-secrets.md index 4e2ab1a059..e63f9bc9fb 100644 --- a/runatlantis.io/docs/webhook-secrets.md +++ b/runatlantis.io/docs/webhook-secrets.md @@ -20,11 +20,6 @@ Azure DevOps uses Basic authentication for webhooks rather than webhook secrets. An app-wide token is generated during [GitHub App setup](access-credentials.md#github-app). You can recover it by navigating to the [GitHub app settings page](https://github.com/settings/apps) and selecting "Edit" next to your Atlantis app's name. Token appears after clicking "Edit" under the Webhook header. ::: -::: warning -Bitbucket.org **does not** support webhook secrets. -To mitigate, use repo allowlists and IP allowlists. See [Security](security.md#bitbucket-cloud-bitbucket-org) for more information. -::: - ## Generating A Webhook Secret You can use any random string generator to create your Webhook secret. It should be > 24 characters. diff --git a/runatlantis.io/guide/testing-locally.md b/runatlantis.io/guide/testing-locally.md index 4e00c923de..ae7131f6af 100644 --- a/runatlantis.io/guide/testing-locally.md +++ b/runatlantis.io/guide/testing-locally.md @@ -48,11 +48,7 @@ URL="https://{YOUR_HOSTNAME}.ngrok.io" GitHub and GitLab use webhook secrets so clients can verify that the webhooks came from them. -::: warning -Bitbucket Cloud (bitbucket.org) doesn't use webhook secrets so if you're using Bitbucket Cloud you can skip this step. -When you're ready to do a production deploy of Atlantis you should allowlist [Bitbucket IPs](https://confluence.atlassian.com/bitbucket/what-are-the-bitbucket-cloud-ip-addresses-i-should-use-to-configure-my-corporate-firewall-343343385.html) -to ensure the webhooks are coming from them. -::: + Create a random string of any length (you can use [random.org](https://www.random.org/strings/)) and set an environment variable: diff --git a/server/controllers/api_controller.go b/server/controllers/api_controller.go index ff85371469..29037ec9a0 100644 --- a/server/controllers/api_controller.go +++ b/server/controllers/api_controller.go @@ -33,6 +33,7 @@ type APIController struct { RepoAllowlistChecker *events.RepoAllowlistChecker Scope tally.Scope VCSClient vcs.Client + CommitStatusUpdater events.CommitStatusUpdater } type APIRequest struct { @@ -150,6 +151,11 @@ func (a *APIController) apiPlan(request *APIRequest, ctx *command.Context) (*com return nil, err } + // Update the combined plan commit status to pending + if err := a.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { + ctx.Log.Warn("unable to update plan commit status: %s", err) + } + var projectResults []command.ProjectResult for i, cmd := range cmds { err = a.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cc[i]) @@ -173,6 +179,11 @@ func (a *APIController) apiApply(request *APIRequest, ctx *command.Context) (*co return nil, err } + // Update the combined apply commit status to pending + if err := a.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Apply); err != nil { + ctx.Log.Warn("unable to update apply commit status: %s", err) + } + var projectResults []command.ProjectResult for i, cmd := range cmds { err = a.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cc[i]) diff --git a/server/controllers/api_controller_test.go b/server/controllers/api_controller_test.go index 3b3aa520aa..778c41bee2 100644 --- a/server/controllers/api_controller_test.go +++ b/server/controllers/api_controller_test.go @@ -94,6 +94,10 @@ func setup(t *testing.T) (controllers.APIController, *MockProjectCommandBuilder, When(postWorkflowHooksCommandRunner.RunPostHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(nil) + commitStatusUpdater := NewMockCommitStatusUpdater() + + When(commitStatusUpdater.UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]())).ThenReturn(nil) + ac := controllers.APIController{ APISecret: []byte(atlantisToken), Locker: locker, @@ -107,6 +111,7 @@ func setup(t *testing.T) (controllers.APIController, *MockProjectCommandBuilder, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, VCSClient: vcsClient, RepoAllowlistChecker: repoAllowlistChecker, + CommitStatusUpdater: commitStatusUpdater, } return ac, projectCommandBuilder, projectCommandRunner } diff --git a/server/controllers/events/events_controller.go b/server/controllers/events/events_controller.go index c838134132..cdd3d3247f 100644 --- a/server/controllers/events/events_controller.go +++ b/server/controllers/events/events_controller.go @@ -22,7 +22,7 @@ import ( "strconv" "strings" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/microcosm-cc/bluemonday" "github.com/pkg/errors" @@ -34,7 +34,7 @@ import ( "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" - gitlab "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) const githubHeader = "X-Github-Event" @@ -50,7 +50,7 @@ const giteaRequestIDHeader = "X-Gitea-Delivery" const bitbucketEventTypeHeader = "X-Event-Key" const bitbucketCloudRequestIDHeader = "X-Request-UUID" const bitbucketServerRequestIDHeader = "X-Request-ID" -const bitbucketServerSignatureHeader = "X-Hub-Signature" +const bitbucketSignatureHeader = "X-Hub-Signature" // The URL used for Azure DevOps test webhooks const azuredevopsTestURL = "https://fabrikam.visualstudio.com/DefaultCollection/_apis/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079" @@ -223,12 +223,19 @@ func (e *VCSEventsController) handleGithubPost(w http.ResponseWriter, r *http.Re func (e *VCSEventsController) handleBitbucketCloudPost(w http.ResponseWriter, r *http.Request) { eventType := r.Header.Get(bitbucketEventTypeHeader) reqID := r.Header.Get(bitbucketCloudRequestIDHeader) + sig := r.Header.Get(bitbucketSignatureHeader) defer r.Body.Close() // nolint: errcheck body, err := io.ReadAll(r.Body) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) return } + if len(e.BitbucketWebhookSecret) > 0 { + if err := bitbucketcloud.ValidateSignature(body, sig, e.BitbucketWebhookSecret); err != nil { + e.respond(w, logging.Warn, http.StatusBadRequest, "%s", errors.Wrap(err, "request did not pass validation").Error()) + return + } + } switch eventType { case bitbucketcloud.PullCreatedHeader, bitbucketcloud.PullUpdatedHeader, bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader: e.Logger.Debug("handling as pull request state changed event") @@ -246,7 +253,7 @@ func (e *VCSEventsController) handleBitbucketCloudPost(w http.ResponseWriter, r func (e *VCSEventsController) handleBitbucketServerPost(w http.ResponseWriter, r *http.Request) { eventType := r.Header.Get(bitbucketEventTypeHeader) reqID := r.Header.Get(bitbucketServerRequestIDHeader) - sig := r.Header.Get(bitbucketServerSignatureHeader) + sig := r.Header.Get(bitbucketSignatureHeader) defer r.Body.Close() // nolint: errcheck body, err := io.ReadAll(r.Body) if err != nil { diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index a9d4fe70a1..d06b237b9f 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" @@ -29,6 +29,7 @@ import ( mock_policy "github.com/runatlantis/atlantis/server/core/runtime/policy/mocks" "github.com/runatlantis/atlantis/server/core/terraform" terraform_mocks "github.com/runatlantis/atlantis/server/core/terraform/mocks" + "github.com/runatlantis/atlantis/server/core/terraform/tfclient" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" @@ -947,6 +948,25 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) { {"exp-output-merge.txt"}, }, }, + { + Description: "1 failing policy and 1 passing policy with --quiet-policy-checks", + RepoDir: "policy-checks-multi-projects", + ModifiedFiles: []string{"dir1/main.tf,", "dir2/main.tf"}, + PolicyCheck: true, + ExpAutoplan: true, + ExpPolicyChecks: true, + ExpQuietPolicyChecks: true, + ExpQuietPolicyCheckFailure: true, + Comments: []string{ + "atlantis apply", + }, + ExpReplies: [][]string{ + {"exp-output-autoplan.txt"}, + {"exp-output-auto-policy-check-quiet.txt"}, + {"exp-output-apply.txt"}, + {"exp-output-merge.txt"}, + }, + }, { Description: "failing policy without policies passing using extra args", RepoDir: "policy-checks-extra-args", @@ -1182,7 +1202,7 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) { userConfig.EnablePolicyChecksFlag = c.PolicyCheck userConfig.QuietPolicyChecks = c.ExpQuietPolicyChecks - ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{}) + ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{userConfig: userConfig}) // Set the repo to be cloned through the testing backdoor. repoDir, headSHA := initializeRepo(t, c.RepoDir) @@ -1273,13 +1293,13 @@ type setupOption struct { allowCommands []command.Name disableAutoplan bool disablePreWorkflowHooks bool + userConfig server.UserConfig } func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) { allowForkPRs := false discardApprovalOnPlan := true dataDir, binDir, cacheDir := mkSubDirs(t) - // Mocks. e2eVCSClient := vcsmocks.NewMockClient() e2eStatusUpdater := &events.DefaultCommitStatusUpdater{Client: e2eVCSClient} @@ -1319,7 +1339,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers mockDownloader := terraform_mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - terraformClient, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", true, false, projectCmdOutputHandler) + terraformClient, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", true, false, projectCmdOutputHandler) Ok(t, err) boltdb, err := db.New(dataDir) Ok(t, err) @@ -1346,6 +1366,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers } } + defaultTFDistribution := terraformClient.DefaultDistribution() defaultTFVersion := terraformClient.DefaultVersion() locker := events.NewDefaultWorkingDirLocker() parser := &config.ParserValidator{} @@ -1429,7 +1450,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers terraformClient, ) - showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTFVersion) + showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion) Ok(t, err) @@ -1440,6 +1461,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers conftextExec.VersionCache = &LocalConftestCache{} policyCheckRunner, err := runtime.NewPolicyCheckStepRunner( + defaultTFDistribution, defaultTFVersion, conftextExec, ) @@ -1451,11 +1473,13 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers Locker: projectLocker, LockURLGenerator: &mockLockURLGenerator{}, InitStepRunner: &runtime.InitStepRunner{ - TerraformExecutor: terraformClient, - DefaultTFVersion: defaultTFVersion, + TerraformExecutor: terraformClient, + DefaultTFDistribution: defaultTFDistribution, + DefaultTFVersion: defaultTFVersion, }, PlanStepRunner: runtime.NewPlanStepRunner( terraformClient, + defaultTFDistribution, defaultTFVersion, statusUpdater, asyncTfExec, @@ -1465,10 +1489,11 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers ApplyStepRunner: &runtime.ApplyStepRunner{ TerraformExecutor: terraformClient, }, - ImportStepRunner: runtime.NewImportStepRunner(terraformClient, defaultTFVersion), - StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTFVersion), + ImportStepRunner: runtime.NewImportStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion), + StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion), RunStepRunner: &runtime.RunStepRunner{ TerraformExecutor: terraformClient, + DefaultTFDistribution: defaultTFDistribution, DefaultTFVersion: defaultTFVersion, ProjectCmdOutputHandler: projectCmdOutputHandler, }, @@ -1487,7 +1512,18 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers pullUpdater := &events.PullUpdater{ HidePrevPlanComments: false, VCSClient: e2eVCSClient, - MarkdownRenderer: events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false), + MarkdownRenderer: events.NewMarkdownRenderer( + false, // gitlabSupportsCommonMark + false, // disableApplyAll + false, // disableApply + false, // disableMarkdownFolding + false, // disableRepoLocking + false, // enableDiffMarkdownFormat + "", // markdownTemplateOverridesDir + "atlantis", // executableName + false, // hideUnchangedPlanComments + opt.userConfig.QuietPolicyChecks, // quietPolicyChecks + ), } autoMerger := &events.AutoMerger{ @@ -1611,6 +1647,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, PullStatusFetcher: backend, DisableAutoplan: opt.disableAutoplan, + CommitStatusUpdater: commitStatusUpdater, } repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") @@ -1695,26 +1732,26 @@ func GitHubPullRequestParsed(headSHA string) *github.PullRequest { headSHA = "13940d121be73f656e2132c6d7b4c8e87878ac8d" } return &github.PullRequest{ - Number: github.Int(2), - State: github.String("open"), - HTMLURL: github.String("htmlurl"), + Number: github.Ptr(2), + State: github.Ptr("open"), + HTMLURL: github.Ptr("htmlurl"), Head: &github.PullRequestBranch{ Repo: &github.Repository{ - FullName: github.String("runatlantis/atlantis-tests"), - CloneURL: github.String("https://github.com/runatlantis/atlantis-tests.git"), + FullName: github.Ptr("runatlantis/atlantis-tests"), + CloneURL: github.Ptr("https://github.com/runatlantis/atlantis-tests.git"), }, - SHA: github.String(headSHA), - Ref: github.String("branch"), + SHA: github.Ptr(headSHA), + Ref: github.Ptr("branch"), }, Base: &github.PullRequestBranch{ Repo: &github.Repository{ - FullName: github.String("runatlantis/atlantis-tests"), - CloneURL: github.String("https://github.com/runatlantis/atlantis-tests.git"), + FullName: github.Ptr("runatlantis/atlantis-tests"), + CloneURL: github.Ptr("https://github.com/runatlantis/atlantis-tests.git"), }, - Ref: github.String("main"), + Ref: github.Ptr("main"), }, User: &github.User{ - Login: github.String("atlantisbot"), + Login: github.Ptr("atlantisbot"), }, } } diff --git a/server/controllers/events/events_controller_test.go b/server/controllers/events/events_controller_test.go index 11bcec3445..f4c563552c 100644 --- a/server/controllers/events/events_controller_test.go +++ b/server/controllers/events/events_controller_test.go @@ -25,7 +25,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/mcdafydd/go-azuredevops/azuredevops" . "github.com/petergtz/pegomock/v4" events_controllers "github.com/runatlantis/atlantis/server/controllers/events" @@ -38,7 +38,7 @@ import ( "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" . "github.com/runatlantis/atlantis/testing" - gitlab "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) const githubHeader = "X-Github-Event" diff --git a/server/controllers/events/github_request_validator.go b/server/controllers/events/github_request_validator.go index 89ae67e6b2..dc8b89f560 100644 --- a/server/controllers/events/github_request_validator.go +++ b/server/controllers/events/github_request_validator.go @@ -19,7 +19,7 @@ import ( "io" "net/http" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" ) //go:generate pegomock generate --package mocks -o mocks/mock_github_request_validator.go GithubRequestValidator diff --git a/server/controllers/events/gitlab_request_parser_validator.go b/server/controllers/events/gitlab_request_parser_validator.go index 5d58dba831..22d2e08e0f 100644 --- a/server/controllers/events/gitlab_request_parser_validator.go +++ b/server/controllers/events/gitlab_request_parser_validator.go @@ -20,7 +20,7 @@ import ( "io" "net/http" - gitlab "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) const secretHeader = "X-Gitlab-Token" // #nosec diff --git a/server/controllers/events/gitlab_request_parser_validator_test.go b/server/controllers/events/gitlab_request_parser_validator_test.go index 184b9f00b7..fb61c4ff9d 100644 --- a/server/controllers/events/gitlab_request_parser_validator_test.go +++ b/server/controllers/events/gitlab_request_parser_validator_test.go @@ -22,7 +22,7 @@ import ( . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/controllers/events" . "github.com/runatlantis/atlantis/testing" - gitlab "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) var parser = events.DefaultGitlabRequestParserValidator{} diff --git a/server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-auto-policy-check-quiet.txt b/server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-auto-policy-check-quiet.txt new file mode 100644 index 0000000000..57a3dfefe3 --- /dev/null +++ b/server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-auto-policy-check-quiet.txt @@ -0,0 +1,44 @@ +Ran Policy Check for 2 projects: + +1. dir: `dir1` workspace: `default` +1. dir: `dir2` workspace: `default` +--- + +### 2. dir: `dir2` workspace: `default` +**Policy Check Failed**: Some policy sets did not pass. +#### Policy Set: `test_policy` +```diff +FAIL - - main - WARNING: Forbidden Resource creation is prohibited. + +1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions + +``` + + +#### Policy Approval Status: +``` +policy set: test_policy: requires: 1 approval(s), have: 0. +``` +* :heavy_check_mark: To **approve** this project, comment: + ```shell + atlantis approve_policies -d dir2 + ``` +* :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) +* :repeat: To re-run policies **plan** this project again by commenting: + ```shell + atlantis plan -d dir2 + ``` + +--- +* :heavy_check_mark: To **approve** all unapplied plans from this Pull Request, comment: + ```shell + atlantis approve_policies + ``` +* :put_litter_in_its_place: To **delete** all plans and locks from this Pull Request, comment: + ```shell + atlantis unlock + ``` +* :repeat: To re-run policies **plan** this project again by commenting: + ```shell + atlantis plan + ``` diff --git a/server/core/config/parser_validator.go b/server/core/config/parser_validator.go index 06e19101e0..0c12b7aeb6 100644 --- a/server/core/config/parser_validator.go +++ b/server/core/config/parser_validator.go @@ -3,6 +3,7 @@ package config import ( "bytes" "encoding/json" + "errors" "fmt" "io" "os" @@ -11,7 +12,7 @@ import ( validation "github.com/go-ozzo/ozzo-validation" shlex "github.com/google/shlex" - "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" yaml "gopkg.in/yaml.v3" @@ -29,11 +30,11 @@ func (p *ParserValidator) HasRepoCfg(absRepoDir, repoConfigFile string) (bool, e const invalidExtensionFilename = "atlantis.yml" _, err := os.Stat(p.repoCfgPath(absRepoDir, invalidExtensionFilename)) if err == nil { - return false, errors.Errorf("found %q as config file; rename using the .yaml extension", invalidExtensionFilename) + return false, fmt.Errorf("found %q as config file; rename using the .yaml extension", invalidExtensionFilename) } _, err = os.Stat(p.repoCfgPath(absRepoDir, repoConfigFile)) - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return false, nil } return err == nil, err @@ -48,12 +49,7 @@ func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.Global configData, err := os.ReadFile(configFile) // nolint: gosec if err != nil { - if !os.IsNotExist(err) { - return valid.RepoCfg{}, errors.Wrapf(err, "unable to read %s file", repoConfigFile) - } - // Don't wrap os.IsNotExist errors because we want our callers to be - // able to detect if it's a NotExist err. - return valid.RepoCfg{}, err + return valid.RepoCfg{}, fmt.Errorf("unable to read %s file: %w", repoConfigFile, err) } return p.ParseRepoCfgData(configData, globalCfg, repoID, branch) } @@ -115,7 +111,7 @@ func (p *ParserValidator) ParseRepoCfgData(repoCfgData []byte, globalCfg valid.G func (p *ParserValidator) ParseGlobalCfg(configFile string, defaultCfg valid.GlobalCfg) (valid.GlobalCfg, error) { configData, err := os.ReadFile(configFile) // nolint: gosec if err != nil { - return valid.GlobalCfg{}, errors.Wrapf(err, "unable to read %s file", configFile) + return valid.GlobalCfg{}, fmt.Errorf("unable to read %s file: %w", configFile, err) } if len(configData) == 0 { return valid.GlobalCfg{}, fmt.Errorf("file %s was empty", configFile) @@ -204,7 +200,7 @@ func (p *ParserValidator) applyLegacyShellParsing(cfg *valid.RepoCfg) error { if s.StepName == "run" { split, err := shlex.Split(s.RunCommand) if err != nil { - return errors.Wrapf(err, "unable to parse %q", s.RunCommand) + return fmt.Errorf("unable to parse %q: %w", s.RunCommand, err) } s.RunCommand = strings.Join(split, " ") } diff --git a/server/core/config/parser_validator_test.go b/server/core/config/parser_validator_test.go index c21187bc47..abe6f98933 100644 --- a/server/core/config/parser_validator_test.go +++ b/server/core/config/parser_validator_test.go @@ -1,7 +1,9 @@ package config_test import ( + "errors" "fmt" + "io/fs" "os" "path/filepath" "regexp" @@ -50,14 +52,14 @@ func TestHasRepoCfg_InvalidFileExtension(t *testing.T) { func TestParseRepoCfg_DirDoesNotExist(t *testing.T) { r := config.ParserValidator{} _, err := r.ParseRepoCfg("/not/exist", globalCfg, "", "") - Assert(t, os.IsNotExist(err), "exp not exist err") + Assert(t, errors.Is(err, fs.ErrNotExist), "exp not exist err") } func TestParseRepoCfg_FileDoesNotExist(t *testing.T) { tmpDir := t.TempDir() r := config.ParserValidator{} _, err := r.ParseRepoCfg(tmpDir, globalCfg, "", "") - Assert(t, os.IsNotExist(err), "exp not exist err") + Assert(t, errors.Is(err, fs.ErrNotExist), "exp not exist err") } func TestParseRepoCfg_BadPermissions(t *testing.T) { @@ -610,6 +612,31 @@ workflows: }, }, }, + { + description: "project field with terraform_distribution set to opentofu", + input: ` +version: 3 +projects: +- dir: . + workspace: myworkspace + terraform_distribution: opentofu +`, + exp: valid.RepoCfg{ + Version: 3, + Projects: []valid.Project{ + { + Dir: ".", + Workspace: "myworkspace", + TerraformDistribution: String("opentofu"), + Autoplan: valid.Autoplan{ + WhenModified: raw.DefaultAutoPlanWhenModified, + Enabled: true, + }, + }, + }, + Workflows: make(map[string]valid.Workflow), + }, + }, { description: "project dir with ..", input: ` diff --git a/server/core/config/raw/autodiscover.go b/server/core/config/raw/autodiscover.go index 156128d271..fdf18fbec4 100644 --- a/server/core/config/raw/autodiscover.go +++ b/server/core/config/raw/autodiscover.go @@ -1,6 +1,11 @@ package raw import ( + "errors" + "fmt" + "strings" + + "github.com/bmatcuk/doublestar/v4" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" ) @@ -8,7 +13,8 @@ import ( var DefaultAutoDiscoverMode = valid.AutoDiscoverAutoMode type AutoDiscover struct { - Mode *valid.AutoDiscoverMode `yaml:"mode,omitempty"` + Mode *valid.AutoDiscoverMode `yaml:"mode,omitempty"` + IgnorePaths []string `yaml:"ignore_paths,omitempty"` } func (a AutoDiscover) ToValid() *valid.AutoDiscover { @@ -20,19 +26,44 @@ func (a AutoDiscover) ToValid() *valid.AutoDiscover { v.Mode = DefaultAutoDiscoverMode } + v.IgnorePaths = a.IgnorePaths + return &v } func (a AutoDiscover) Validate() error { + + ignoreValid := func(value interface{}) error { + strSlice := value.([]string) + if strSlice == nil { + return nil + } + for _, ignore := range strSlice { + // A beginning slash isn't necessary since they are specifying a relative path, not an absolute one. + // Rejecting `/...` also allows us to potentially use `/.*/` as regexes in the future + if strings.HasPrefix(ignore, "/") { + return errors.New("pattern must not begin with a slash '/'") + } + + if !doublestar.ValidatePattern(ignore) { + return fmt.Errorf("invalid pattern: %s", ignore) + } + + } + return nil + } + res := validation.ValidateStruct(&a, // If a.Mode is nil, this should still pass validation. validation.Field(&a.Mode, validation.In(valid.AutoDiscoverAutoMode, valid.AutoDiscoverDisabledMode, valid.AutoDiscoverEnabledMode)), + validation.Field(&a.IgnorePaths, validation.By(ignoreValid)), ) return res } func DefaultAutoDiscover() *valid.AutoDiscover { return &valid.AutoDiscover{ - Mode: DefaultAutoDiscoverMode, + Mode: DefaultAutoDiscoverMode, + IgnorePaths: nil, } } diff --git a/server/core/config/raw/autodiscover_test.go b/server/core/config/raw/autodiscover_test.go index 9164913126..a1b45c77ee 100644 --- a/server/core/config/raw/autodiscover_test.go +++ b/server/core/config/raw/autodiscover_test.go @@ -19,16 +19,20 @@ func TestAutoDiscover_UnmarshalYAML(t *testing.T) { description: "omit unset fields", input: "", exp: raw.AutoDiscover{ - Mode: nil, + Mode: nil, + IgnorePaths: nil, }, }, { description: "all fields set", input: ` mode: enabled +ignore_paths: + - foobar `, exp: raw.AutoDiscover{ - Mode: &autoDiscoverEnabled, + Mode: &autoDiscoverEnabled, + IgnorePaths: []string{"foobar"}, }, }, } @@ -86,6 +90,67 @@ func TestAutoDiscover_Validate(t *testing.T) { }, errContains: String("valid value"), }, + { + description: "ignore set with leading slash", + input: raw.AutoDiscover{ + Mode: &autoDiscoverAuto, + IgnorePaths: []string{ + "/foo", + }, + }, + errContains: String("pattern must not begin with a slash '/'"), + }, + { + description: `ignore set to broken pattern \`, + input: raw.AutoDiscover{ + Mode: &autoDiscoverAuto, + IgnorePaths: []string{ + `\`, + }, + }, + errContains: String(`invalid pattern: \`), + }, + { + description: "ignore set to broken pattern [", + input: raw.AutoDiscover{ + Mode: &autoDiscoverAuto, + IgnorePaths: []string{ + "[", + }, + }, + errContains: String("invalid pattern: ["), + }, + { + description: "ignore set to valid pattern", + input: raw.AutoDiscover{ + Mode: &autoDiscoverAuto, + IgnorePaths: []string{ + "foo*", + }, + }, + errContains: nil, + }, + { + description: "ignore set to long pattern", + input: raw.AutoDiscover{ + Mode: &autoDiscoverAuto, + IgnorePaths: []string{ + "foo/**/bar/baz/??", + }, + }, + errContains: nil, + }, + { + description: "ignore set to one valid and one invalid pattern", + input: raw.AutoDiscover{ + Mode: &autoDiscoverAuto, + IgnorePaths: []string{ + "foo", + "foo[", + }, + }, + errContains: String("invalid pattern: foo["), + }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { @@ -109,16 +174,25 @@ func TestAutoDiscover_ToValid(t *testing.T) { description: "nothing set", input: raw.AutoDiscover{}, exp: &valid.AutoDiscover{ - Mode: valid.AutoDiscoverAutoMode, + Mode: valid.AutoDiscoverAutoMode, + IgnorePaths: nil, }, }, { description: "value set", input: raw.AutoDiscover{ Mode: &autoDiscoverEnabled, + IgnorePaths: []string{ + "foo", + "bar/*", + }, }, exp: &valid.AutoDiscover{ Mode: valid.AutoDiscoverEnabledMode, + IgnorePaths: []string{ + "foo", + "bar/*", + }, }, }, } diff --git a/server/core/config/raw/global_cfg.go b/server/core/config/raw/global_cfg.go index bdc1f6697d..127e22e3cf 100644 --- a/server/core/config/raw/global_cfg.go +++ b/server/core/config/raw/global_cfg.go @@ -1,12 +1,12 @@ package raw import ( + "errors" "fmt" "regexp" "strings" validation "github.com/go-ozzo/ozzo-validation" - "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/utils" ) @@ -184,7 +184,10 @@ func (r Repo) Validate() error { return nil } _, err := regexp.Compile(id[1 : len(id)-1]) - return errors.Wrapf(err, "parsing: %s", id) + if err != nil { + return fmt.Errorf("parsing: %s: %w", id, err) + } + return nil } branchValid := func(value interface{}) error { @@ -197,7 +200,10 @@ func (r Repo) Validate() error { } withoutSlashes := branch[1 : len(branch)-1] _, err := regexp.Compile(withoutSlashes) - return errors.Wrapf(err, "parsing: %s", branch) + if err != nil { + return fmt.Errorf("parsing: %s: %w", branch, err) + } + return nil } repoConfigFileValid := func(value interface{}) error { diff --git a/server/core/config/raw/project.go b/server/core/config/raw/project.go index fe0e656a8c..74d12539bf 100644 --- a/server/core/config/raw/project.go +++ b/server/core/config/raw/project.go @@ -1,6 +1,7 @@ package raw import ( + "errors" "fmt" "net/url" "path/filepath" @@ -9,7 +10,6 @@ import ( validation "github.com/go-ozzo/ozzo-validation" version "github.com/hashicorp/go-version" - "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/config/valid" ) @@ -26,6 +26,7 @@ type Project struct { Dir *string `yaml:"dir,omitempty"` Workspace *string `yaml:"workspace,omitempty"` Workflow *string `yaml:"workflow,omitempty"` + TerraformDistribution *string `yaml:"terraform_distribution,omitempty"` TerraformVersion *string `yaml:"terraform_version,omitempty"` Autoplan *Autoplan `yaml:"autoplan,omitempty"` PlanRequirements []string `yaml:"plan_requirements,omitempty"` @@ -74,7 +75,10 @@ func (p Project) Validate() error { } withoutSlashes := branch[1 : len(branch)-1] _, err := regexp.Compile(withoutSlashes) - return errors.Wrapf(err, "parsing: %s", branch) + if err != nil { + return fmt.Errorf("parsing: %s: %w", branch, err) + } + return nil } DependsOn := func(value interface{}) error { @@ -86,6 +90,7 @@ func (p Project) Validate() error { validation.Field(&p.PlanRequirements, validation.By(validPlanReq)), validation.Field(&p.ApplyRequirements, validation.By(validApplyReq)), validation.Field(&p.ImportRequirements, validation.By(validImportReq)), + validation.Field(&p.TerraformDistribution, validation.By(validDistribution)), validation.Field(&p.TerraformVersion, validation.By(VersionValidator)), validation.Field(&p.DependsOn, validation.By(DependsOn)), validation.Field(&p.Name, validation.By(validName)), @@ -118,6 +123,9 @@ func (p Project) ToValid() valid.Project { if p.TerraformVersion != nil { v.TerraformVersion, _ = version.NewVersion(*p.TerraformVersion) } + if p.TerraformDistribution != nil { + v.TerraformDistribution = p.TerraformDistribution + } if p.Autoplan == nil { v.Autoplan = DefaultAutoPlan() } else { @@ -202,3 +210,11 @@ func validImportReq(value interface{}) error { } return nil } + +func validDistribution(value interface{}) error { + distribution := value.(*string) + if distribution != nil && *distribution != "terraform" && *distribution != "opentofu" { + return fmt.Errorf("'%s' is not a valid terraform_distribution, only '%s' and '%s' are supported", *distribution, "terraform", "opentofu") + } + return nil +} diff --git a/server/core/config/raw/raw.go b/server/core/config/raw/raw.go index d10625255c..97dd0615a3 100644 --- a/server/core/config/raw/raw.go +++ b/server/core/config/raw/raw.go @@ -4,8 +4,9 @@ package raw import ( + "fmt" + version "github.com/hashicorp/go-version" - "github.com/pkg/errors" ) // VersionValidator helper function to validate binary version. @@ -16,5 +17,8 @@ func VersionValidator(value interface{}) error { return nil } _, err := version.NewVersion(*strPtr) - return errors.Wrapf(err, "version %q could not be parsed", *strPtr) + if err != nil { + return fmt.Errorf("version %q could not be parsed: %w", *strPtr, err) + } + return nil } diff --git a/server/core/config/raw/raw_test.go b/server/core/config/raw/raw_test.go index fd1886cdbe..0d8fdca9a3 100644 --- a/server/core/config/raw/raw_test.go +++ b/server/core/config/raw/raw_test.go @@ -4,7 +4,8 @@ import ( "io" "strings" - "github.com/pkg/errors" + "errors" + "gopkg.in/yaml.v3" ) diff --git a/server/core/config/raw/repo_cfg_test.go b/server/core/config/raw/repo_cfg_test.go index 245f2d56d2..2c405da34f 100644 --- a/server/core/config/raw/repo_cfg_test.go +++ b/server/core/config/raw/repo_cfg_test.go @@ -130,6 +130,8 @@ version: 3 automerge: true autodiscover: mode: enabled + ignore_paths: + - foo/* parallel_apply: true parallel_plan: false repo_locks: @@ -157,8 +159,11 @@ allowed_regexp_prefixes: - dev/ - staging/`, exp: raw.RepoCfg{ - Version: Int(3), - AutoDiscover: &raw.AutoDiscover{Mode: &autoDiscoverEnabled}, + Version: Int(3), + AutoDiscover: &raw.AutoDiscover{ + Mode: &autoDiscoverEnabled, + IgnorePaths: []string{"foo/*"}, + }, Automerge: Bool(true), ParallelApply: Bool(true), ParallelPlan: Bool(false), diff --git a/server/core/config/valid/autodiscover.go b/server/core/config/valid/autodiscover.go index c131c3bffe..1e78309a54 100644 --- a/server/core/config/valid/autodiscover.go +++ b/server/core/config/valid/autodiscover.go @@ -10,5 +10,6 @@ const ( ) type AutoDiscover struct { - Mode AutoDiscoverMode + Mode AutoDiscoverMode + IgnorePaths []string } diff --git a/server/core/config/valid/global_cfg.go b/server/core/config/valid/global_cfg.go index b0bdc86822..48a78f7158 100644 --- a/server/core/config/valid/global_cfg.go +++ b/server/core/config/valid/global_cfg.go @@ -105,6 +105,7 @@ type MergedProjectCfg struct { AutoplanEnabled bool AutoMergeDisabled bool AutoMergeMethod string + TerraformDistribution *string TerraformVersion *version.Version RepoCfgVersion int PolicySets PolicySets @@ -412,6 +413,7 @@ func (g GlobalCfg) MergeProjectCfg(log logging.SimpleLogging, repoID string, pro DependsOn: proj.DependsOn, Name: proj.GetName(), AutoplanEnabled: proj.Autoplan.Enabled, + TerraformDistribution: proj.TerraformDistribution, TerraformVersion: proj.TerraformVersion, RepoCfgVersion: rCfg.Version, PolicySets: g.PolicySets, @@ -438,6 +440,7 @@ func (g GlobalCfg) DefaultProjCfg(log logging.SimpleLogging, repoID string, repo Workspace: workspace, Name: "", AutoplanEnabled: DefaultAutoPlanEnabled, + TerraformDistribution: nil, TerraformVersion: nil, PolicySets: g.PolicySets, DeleteSourceBranchOnMerge: deleteSourceBranchOnMerge, diff --git a/server/core/config/valid/policies.go b/server/core/config/valid/policies.go index 6aee54179c..718338d05b 100644 --- a/server/core/config/valid/policies.go +++ b/server/core/config/valid/policies.go @@ -1,6 +1,7 @@ package valid import ( + "slices" "strings" version "github.com/hashicorp/go-version" @@ -67,3 +68,16 @@ func (o *PolicyOwners) IsOwner(username string, userTeams []string) bool { return false } + +// Return all owner teams from all policy sets +func (p *PolicySets) AllTeams() []string { + teams := p.Owners.Teams + for _, policySet := range p.PolicySets { + for _, team := range policySet.Owners.Teams { + if !slices.Contains(teams, team) { + teams = append(teams, team) + } + } + } + return teams +} diff --git a/server/core/config/valid/policies_test.go b/server/core/config/valid/policies_test.go index c575a4585a..5147dd8686 100644 --- a/server/core/config/valid/policies_test.go +++ b/server/core/config/valid/policies_test.go @@ -120,3 +120,66 @@ func TestPoliciesConfig_IsOwners(t *testing.T) { }) } } + +func TestPoliciesConfig_AllTeams(t *testing.T) { + cases := []struct { + description string + input valid.PolicySets + expResult []string + }{ + { + description: "has only top-level team owner", + input: valid.PolicySets{ + Owners: valid.PolicyOwners{ + Teams: []string{ + "team1", + }, + }, + }, + expResult: []string{"team1"}, + }, + { + description: "has only policy-level team owner", + input: valid.PolicySets{ + PolicySets: []valid.PolicySet{ + { + Name: "policy1", + Owners: valid.PolicyOwners{ + Teams: []string{ + "team2", + }, + }, + }, + }, + }, + expResult: []string{"team2"}, + }, + { + description: "has both top-level and policy-level team owners", + input: valid.PolicySets{ + Owners: valid.PolicyOwners{ + Teams: []string{ + "team1", + }, + }, + PolicySets: []valid.PolicySet{ + { + Name: "policy1", + Owners: valid.PolicyOwners{ + Teams: []string{ + "team2", + }, + }, + }, + }, + }, + expResult: []string{"team1", "team2"}, + }, + } + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + result := c.input.AllTeams() + Equals(t, c.expResult, result) + }) + } +} diff --git a/server/core/config/valid/repo_cfg.go b/server/core/config/valid/repo_cfg.go index 4612f72cec..afdb31412b 100644 --- a/server/core/config/valid/repo_cfg.go +++ b/server/core/config/valid/repo_cfg.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/bmatcuk/doublestar/v4" version "github.com/hashicorp/go-version" ) @@ -111,6 +112,21 @@ func (r RepoCfg) AutoDiscoverEnabled(defaultAutoDiscoverMode AutoDiscoverMode) b return autoDiscoverMode == AutoDiscoverEnabledMode } +func (r RepoCfg) IsPathIgnoredForAutoDiscover(path string) bool { + if r.AutoDiscover == nil || r.AutoDiscover.IgnorePaths == nil { + return false + } + for i := 0; i < len(r.AutoDiscover.IgnorePaths); i++ { + // Per documentation https://pkg.go.dev/github.com/bmatcuk/doublestar, if you run ValidatePattern() + // against a pattern, which we do, you can run MatchUnvalidated for a slight performance gain, + // and also no need to explicitly check for an error + if doublestar.MatchUnvalidated(r.AutoDiscover.IgnorePaths[i], path) { + return true + } + } + return false +} + // validateWorkspaceAllowed returns an error if repoCfg defines projects in // repoRelDir but none of them use workspace. We want this to be an error // because if users have gone to the trouble of defining projects in repoRelDir @@ -147,6 +163,7 @@ type Project struct { Workspace string Name *string WorkflowName *string + TerraformDistribution *string TerraformVersion *version.Version Autoplan Autoplan PlanRequirements []string diff --git a/server/core/config/valid/repo_cfg_test.go b/server/core/config/valid/repo_cfg_test.go index 9b94994f51..c35870ee60 100644 --- a/server/core/config/valid/repo_cfg_test.go +++ b/server/core/config/valid/repo_cfg_test.go @@ -316,10 +316,102 @@ func TestConfig_AutoDiscoverEnabled(t *testing.T) { AutoDiscover: nil, } if c.repoAutoDiscover != "" { - r.AutoDiscover = &valid.AutoDiscover{c.repoAutoDiscover} + r.AutoDiscover = &valid.AutoDiscover{ + Mode: c.repoAutoDiscover, + } } enabled := r.AutoDiscoverEnabled(c.defaultAutoDiscover) Equals(t, c.expEnabled, enabled) }) } } + +func TestConfig_IsPathIgnoredForAutoDiscover(t *testing.T) { + cases := []struct { + description string + repoCfg valid.RepoCfg + path string + expIgnored bool + }{ + { + description: "auto discover unconfigured", + repoCfg: valid.RepoCfg{}, + path: "foo", + expIgnored: false, + }, + { + description: "auto discover configured, but not path", + repoCfg: valid.RepoCfg{ + AutoDiscover: &valid.AutoDiscover{}, + }, + path: "foo", + expIgnored: false, + }, + { + description: "paths do not match pattern", + repoCfg: valid.RepoCfg{ + AutoDiscover: &valid.AutoDiscover{ + IgnorePaths: []string{ + "bar", + }, + }, + }, + path: "foo", + expIgnored: false, + }, + { + description: "path does match pattern", + repoCfg: valid.RepoCfg{ + AutoDiscover: &valid.AutoDiscover{ + IgnorePaths: []string{ + "fo?", + }}, + }, + path: "foo", + expIgnored: true, + }, + { + description: "one path matches pattern, another doesn't", + repoCfg: valid.RepoCfg{ + AutoDiscover: &valid.AutoDiscover{ + IgnorePaths: []string{ + "fo*", + "ba*", + }}, + }, + path: "foo", + expIgnored: true, + }, + { + description: "long path does match pattern", + repoCfg: valid.RepoCfg{ + AutoDiscover: &valid.AutoDiscover{ + IgnorePaths: []string{ + "foo/*/baz", + }, + }, + }, + path: "foo/bar/baz", + expIgnored: true, + }, + { + description: "long path does not match pattern", + repoCfg: valid.RepoCfg{ + AutoDiscover: &valid.AutoDiscover{ + IgnorePaths: []string{ + "foo/*/baz", + }, + }, + }, + path: "foo/bar/boo", + expIgnored: false, + }, + } + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + + ignored := c.repoCfg.IsPathIgnoredForAutoDiscover(c.path) + Equals(t, c.expIgnored, ignored) + }) + } +} diff --git a/server/core/runtime/apply_step_runner.go b/server/core/runtime/apply_step_runner.go index 2e223f2996..35a864cfc8 100644 --- a/server/core/runtime/apply_step_runner.go +++ b/server/core/runtime/apply_step_runner.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" version "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/utils" @@ -17,10 +18,11 @@ import ( // ApplyStepRunner runs `terraform apply`. type ApplyStepRunner struct { - TerraformExecutor TerraformExec - DefaultTFVersion *version.Version - CommitStatusUpdater StatusUpdater - AsyncTFExec AsyncTFExec + TerraformExecutor TerraformExec + DefaultTFDistribution terraform.Distribution + DefaultTFVersion *version.Version + CommitStatusUpdater StatusUpdater + AsyncTFExec AsyncTFExec } func (a *ApplyStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { @@ -39,11 +41,19 @@ func (a *ApplyStepRunner) Run(ctx command.ProjectContext, extraArgs []string, pa ctx.Log.Info("starting apply") var out string + tfDistribution := a.DefaultTFDistribution + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } + tfVersion := a.DefaultTFVersion + if ctx.TerraformVersion != nil { + tfVersion = ctx.TerraformVersion + } // TODO: Leverage PlanTypeStepRunnerDelegate here if IsRemotePlan(contents) { args := append(append([]string{"apply", "-input=false", "-no-color"}, extraArgs...), ctx.EscapedCommentArgs...) - out, err = a.runRemoteApply(ctx, args, path, planPath, ctx.TerraformVersion, envs) + out, err = a.runRemoteApply(ctx, args, path, planPath, tfDistribution, tfVersion, envs) if err == nil { out = a.cleanRemoteApplyOutput(out) } @@ -51,7 +61,7 @@ func (a *ApplyStepRunner) Run(ctx command.ProjectContext, extraArgs []string, pa // NOTE: we need to quote the plan path because Bitbucket Server can // have spaces in its repo owner names which is part of the path. args := append(append(append([]string{"apply", "-input=false"}, extraArgs...), ctx.EscapedCommentArgs...), fmt.Sprintf("%q", planPath)) - out, err = a.TerraformExecutor.RunCommandWithVersion(ctx, path, args, envs, ctx.TerraformVersion, ctx.Workspace) + out, err = a.TerraformExecutor.RunCommandWithVersion(ctx, path, args, envs, tfDistribution, tfVersion, ctx.Workspace) } // If the apply was successful, delete the plan. @@ -115,6 +125,7 @@ func (a *ApplyStepRunner) runRemoteApply( applyArgs []string, path string, absPlanPath string, + tfDistribution terraform.Distribution, tfVersion *version.Version, envs map[string]string) (string, error) { // The planfile contents are needed to ensure that the plan didn't change @@ -133,7 +144,7 @@ func (a *ApplyStepRunner) runRemoteApply( // Start the async command execution. ctx.Log.Debug("starting async tf remote operation") - inCh, outCh := a.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), applyArgs, envs, tfVersion, ctx.Workspace) + inCh, outCh := a.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), applyArgs, envs, tfDistribution, tfVersion, ctx.Workspace) var lines []string nextLineIsRunURL := false var runURL string diff --git a/server/core/runtime/apply_step_runner_test.go b/server/core/runtime/apply_step_runner_test.go index 2a31040c81..d9be33e1d6 100644 --- a/server/core/runtime/apply_step_runner_test.go +++ b/server/core/runtime/apply_step_runner_test.go @@ -14,7 +14,9 @@ import ( "github.com/runatlantis/atlantis/server/core/runtime" runtimemocks "github.com/runatlantis/atlantis/server/core/runtime/mocks" runtimemodels "github.com/runatlantis/atlantis/server/core/runtime/models" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" @@ -59,17 +61,20 @@ func TestRun_Success(t *testing.T) { Ok(t, err) RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) o := runtime.ApplyStepRunner{ - TerraformExecutor: terraform, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, } - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), nil, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), tfDistribution, nil, "workspace") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } @@ -91,22 +96,24 @@ func TestRun_AppliesCorrectProjectPlan(t *testing.T) { Ok(t, err) RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) o := runtime.ApplyStepRunner{ - TerraformExecutor: terraform, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, } - - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), nil, "default") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), tfDistribution, nil, "default") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } -func TestRun_UsesConfiguredTFVersion(t *testing.T) { +func TestApplyStepRunner_TestRun_UsesConfiguredTFVersion(t *testing.T) { tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, "workspace.tfplan") err := os.WriteFile(planPath, nil, 0600) @@ -123,17 +130,55 @@ func TestRun_UsesConfiguredTFVersion(t *testing.T) { } RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) o := runtime.ApplyStepRunner{ - TerraformExecutor: terraform, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, } + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). + ThenReturn("output", nil) + output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) + Ok(t, err) + Equals(t, "output", output) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), tfDistribution, tfVersion, "workspace") + _, err = os.Stat(planPath) + Assert(t, os.IsNotExist(err), "planfile should be deleted") +} - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). +func TestApplyStepRunner_TestRun_UsesConfiguredDistribution(t *testing.T) { + tmpDir := t.TempDir() + planPath := filepath.Join(tmpDir, "workspace.tfplan") + err := os.WriteFile(planPath, nil, 0600) + Ok(t, err) + + logger := logging.NewNoopLogger(t) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + tfVersion, _ := version.NewVersion("0.11.0") + projTFDistribution := "opentofu" + ctx := command.ProjectContext{ + Workspace: "workspace", + RepoRelDir: ".", + EscapedCommentArgs: []string{"comment", "args"}, + TerraformDistribution: &projTFDistribution, + Log: logger, + } + + RegisterMockTestingT(t) + terraform := tfclientmocks.NewMockClient() + o := runtime.ApplyStepRunner{ + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, + } + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), NotEq[tf.Distribution](tfDistribution), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(ctx), Eq(tmpDir), Eq([]string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}), Eq(map[string]string(nil)), NotEq[tf.Distribution](tfDistribution), Eq(tfVersion), Eq("workspace")) _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } @@ -197,7 +242,7 @@ func TestRun_UsingTarget(t *testing.T) { planPath := filepath.Join(tmpDir, "workspace.tfplan") err := os.WriteFile(planPath, nil, 0600) Ok(t, err) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() step := runtime.ApplyStepRunner{ TerraformExecutor: terraform, } @@ -361,7 +406,7 @@ type remoteApplyMock struct { } // RunCommandAsync fakes out running terraform async. -func (r *remoteApplyMock) RunCommandAsync(_ command.ProjectContext, _ string, args []string, _ map[string]string, _ *version.Version, _ string) (chan<- string, <-chan runtimemodels.Line) { +func (r *remoteApplyMock) RunCommandAsync(_ command.ProjectContext, _ string, args []string, _ map[string]string, _ tf.Distribution, _ *version.Version, _ string) (chan<- string, <-chan runtimemodels.Line) { r.CalledArgs = args in := make(chan string) diff --git a/server/core/runtime/env_step_runner_test.go b/server/core/runtime/env_step_runner_test.go index 0fe86f77f0..7772d56c5f 100644 --- a/server/core/runtime/env_step_runner_test.go +++ b/server/core/runtime/env_step_runner_test.go @@ -5,7 +5,9 @@ import ( "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/runtime" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" @@ -38,12 +40,15 @@ func TestEnvStepRunner_Run(t *testing.T) { }, } RegisterMockTestingT(t) - tfClient := mocks.NewMockClient() + tfClient := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, err := version.NewVersion("0.12.0") Ok(t, err) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() runStepRunner := runtime.RunStepRunner{ TerraformExecutor: tfClient, + DefaultTFDistribution: tfDistribution, DefaultTFVersion: tfVersion, ProjectCmdOutputHandler: projectCmdOutputHandler, } diff --git a/server/core/runtime/import_step_runner.go b/server/core/runtime/import_step_runner.go index 0d5787a8ad..7f3a22b9b4 100644 --- a/server/core/runtime/import_step_runner.go +++ b/server/core/runtime/import_step_runner.go @@ -5,25 +5,32 @@ import ( "path/filepath" version "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/utils" ) type importStepRunner struct { - terraformExecutor TerraformExec - defaultTFVersion *version.Version + terraformExecutor TerraformExec + defaultTFDistribution terraform.Distribution + defaultTFVersion *version.Version } -func NewImportStepRunner(terraformExecutor TerraformExec, defaultTfVersion *version.Version) Runner { +func NewImportStepRunner(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version) Runner { runner := &importStepRunner{ - terraformExecutor: terraformExecutor, - defaultTFVersion: defaultTfVersion, + terraformExecutor: terraformExecutor, + defaultTFDistribution: defaultTfDistribution, + defaultTFVersion: defaultTfVersion, } - return NewWorkspaceStepRunnerDelegate(terraformExecutor, defaultTfVersion, runner) + return NewWorkspaceStepRunnerDelegate(terraformExecutor, defaultTfDistribution, defaultTfVersion, runner) } func (p *importStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { + tfDistribution := p.defaultTFDistribution tfVersion := p.defaultTFVersion + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } @@ -31,7 +38,7 @@ func (p *importStepRunner) Run(ctx command.ProjectContext, extraArgs []string, p importCmd := []string{"import"} importCmd = append(importCmd, extraArgs...) importCmd = append(importCmd, ctx.EscapedCommentArgs...) - out, err := p.terraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), importCmd, envs, tfVersion, ctx.Workspace) + out, err := p.terraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), importCmd, envs, tfDistribution, tfVersion, ctx.Workspace) // If the import was successful and a plan file exists, delete the plan. planPath := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)) diff --git a/server/core/runtime/import_step_runner_test.go b/server/core/runtime/import_step_runner_test.go index b10f182de9..d7cacf9a5f 100644 --- a/server/core/runtime/import_step_runner_test.go +++ b/server/core/runtime/import_step_runner_test.go @@ -8,7 +8,9 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" @@ -29,17 +31,19 @@ func TestImportStepRunner_Run_Success(t *testing.T) { } RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.15.0") - s := NewImportStepRunner(terraform, tfVersion) + s := NewImportStepRunner(terraform, tfDistribution, tfVersion) - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) commands := []string{"import", "-var", "foo=bar", "addr", "id"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfDistribution, tfVersion, workspace) _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } @@ -59,23 +63,66 @@ func TestImportStepRunner_Run_Workspace(t *testing.T) { } RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() tfVersion, _ := version.NewVersion("0.15.0") - s := NewImportStepRunner(terraform, tfVersion) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + s := NewImportStepRunner(terraform, tfDistribution, tfVersion) - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) // switch workspace - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "show"}, map[string]string(nil), tfVersion, workspace) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "select", workspace}, map[string]string(nil), tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "show"}, map[string]string(nil), tfDistribution, tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "select", workspace}, map[string]string(nil), tfDistribution, tfVersion, workspace) // exec import commands := []string{"import", "-var", "foo=bar", "addr", "id"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfDistribution, tfVersion, workspace) + + _, err = os.Stat(planPath) + Assert(t, os.IsNotExist(err), "planfile should be deleted") +} + +func TestImportStepRunner_Run_UsesConfiguredDistribution(t *testing.T) { + logger := logging.NewNoopLogger(t) + workspace := "something" + tmpDir := t.TempDir() + planPath := filepath.Join(tmpDir, fmt.Sprintf("%s.tfplan", workspace)) + err := os.WriteFile(planPath, nil, 0600) + Ok(t, err) + + projTFDistribution := "opentofu" + context := command.ProjectContext{ + Log: logger, + EscapedCommentArgs: []string{"-var", "foo=bar", "addr", "id"}, + Workspace: workspace, + TerraformDistribution: &projTFDistribution, + } + + RegisterMockTestingT(t) + terraform := tfclientmocks.NewMockClient() + tfVersion, _ := version.NewVersion("0.15.0") + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + s := NewImportStepRunner(terraform, tfDistribution, tfVersion) + + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). + ThenReturn("output", nil) + output, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) + Ok(t, err) + Equals(t, "output", output) + + // switch workspace + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(context), Eq(tmpDir), Eq([]string{"workspace", "show"}), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq(workspace)) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(context), Eq(tmpDir), Eq([]string{"workspace", "select", workspace}), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq(workspace)) + + // exec import + commands := []string{"import", "-var", "foo=bar", "addr", "id"} + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(context), Eq(tmpDir), Eq(commands), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq(workspace)) _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") diff --git a/server/core/runtime/init_step_runner.go b/server/core/runtime/init_step_runner.go index 0c6de1b013..c8da3ffa48 100644 --- a/server/core/runtime/init_step_runner.go +++ b/server/core/runtime/init_step_runner.go @@ -5,14 +5,16 @@ import ( version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/runtime/common" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/utils" ) // InitStep runs `terraform init`. type InitStepRunner struct { - TerraformExecutor TerraformExec - DefaultTFVersion *version.Version + TerraformExecutor TerraformExec + DefaultTFDistribution terraform.Distribution + DefaultTFVersion *version.Version } func (i *InitStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { @@ -33,6 +35,11 @@ func (i *InitStepRunner) Run(ctx command.ProjectContext, extraArgs []string, pat } } + tfDistribution := i.DefaultTFDistribution + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } + tfVersion := i.DefaultTFVersion if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion @@ -56,7 +63,7 @@ func (i *InitStepRunner) Run(ctx command.ProjectContext, extraArgs []string, pat terraformInitCmd := append(terraformInitVerb, finalArgs...) - out, err := i.TerraformExecutor.RunCommandWithVersion(ctx, path, terraformInitCmd, envs, tfVersion, ctx.Workspace) + out, err := i.TerraformExecutor.RunCommandWithVersion(ctx, path, terraformInitCmd, envs, tfDistribution, tfVersion, ctx.Workspace) // Only include the init output if there was an error. Otherwise it's // unnecessary and lengthens the comment. if err != nil { diff --git a/server/core/runtime/init_step_runner_test.go b/server/core/runtime/init_step_runner_test.go index 45927591a6..86d029c2d8 100644 --- a/server/core/runtime/init_step_runner_test.go +++ b/server/core/runtime/init_step_runner_test.go @@ -12,7 +12,9 @@ import ( "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/runtime" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" @@ -20,6 +22,8 @@ import ( func TestRun_UsesGetOrInitForRightVersion(t *testing.T) { RegisterMockTestingT(t) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) cases := []struct { version string expCmd string @@ -44,7 +48,7 @@ func TestRun_UsesGetOrInitForRightVersion(t *testing.T) { for _, c := range cases { t.Run(c.version, func(t *testing.T) { - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ @@ -55,10 +59,11 @@ func TestRun_UsesGetOrInitForRightVersion(t *testing.T) { tfVersion, _ := version.NewVersion(c.version) iso := runtime.InitStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := iso.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) @@ -71,7 +76,74 @@ func TestRun_UsesGetOrInitForRightVersion(t *testing.T) { if c.expCmd == "get" { expArgs = []string{c.expCmd, "-upgrade", "extra", "args"} } - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") + }) + } +} + +func TestInitStepRunner_TestRun_UsesConfiguredDistribution(t *testing.T) { + RegisterMockTestingT(t) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + cases := []struct { + version string + distribution string + expCmd string + }{ + { + "0.8.9", + "opentofu", + "get", + }, + { + "0.8.9", + "terraform", + "get", + }, + { + "0.9.0", + "opentofu", + "init", + }, + { + "0.9.1", + "terraform", + "init", + }, + } + + for _, c := range cases { + t.Run(c.version, func(t *testing.T) { + terraform := tfclientmocks.NewMockClient() + + logger := logging.NewNoopLogger(t) + ctx := command.ProjectContext{ + Workspace: "workspace", + RepoRelDir: ".", + Log: logger, + TerraformDistribution: &c.distribution, + } + + tfVersion, _ := version.NewVersion(c.version) + iso := runtime.InitStepRunner{ + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, + } + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). + ThenReturn("output", nil) + + output, err := iso.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) + Ok(t, err) + // When there is no error, should not return init output to PR. + Equals(t, "", output) + + // If using init then we specify -input=false but not for get. + expArgs := []string{c.expCmd, "-input=false", "-upgrade", "extra", "args"} + if c.expCmd == "get" { + expArgs = []string{c.expCmd, "-upgrade", "extra", "args"} + } + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(ctx), Eq("/path"), Eq(expArgs), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq("workspace")) }) } } @@ -79,15 +151,17 @@ func TestRun_UsesGetOrInitForRightVersion(t *testing.T) { func TestRun_ShowInitOutputOnError(t *testing.T) { // If there was an error during init then we want the output to be returned. RegisterMockTestingT(t) - tfClient := mocks.NewMockClient() + tfClient := tfclientmocks.NewMockClient() logger := logging.NewNoopLogger(t) - When(tfClient.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(tfClient.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", errors.New("error")) - + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.11.0") iso := runtime.InitStepRunner{ - TerraformExecutor: tfClient, - DefaultTFVersion: tfVersion, + TerraformExecutor: tfClient, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } output, err := iso.Run(command.ProjectContext{ @@ -118,14 +192,16 @@ func TestRun_InitOmitsUpgradeFlagIfLockFileTracked(t *testing.T) { } RegisterMockTestingT(t) - terraform := mocks.NewMockClient() - + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.14.0") iso := runtime.InitStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := iso.Run(ctx, []string{"extra", "args"}, repoDir, map[string]string(nil)) @@ -134,27 +210,29 @@ func TestRun_InitOmitsUpgradeFlagIfLockFileTracked(t *testing.T) { Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, repoDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, repoDir, expectedArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") } func TestRun_InitKeepsUpgradeFlagIfLockFileNotPresent(t *testing.T) { tmpDir := t.TempDir() RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", Log: logger, } - + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.14.0") iso := runtime.InitStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := iso.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) @@ -163,7 +241,7 @@ func TestRun_InitKeepsUpgradeFlagIfLockFileNotPresent(t *testing.T) { Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") } func TestRun_InitKeepUpgradeFlagIfLockFilePresentAndTFLessThanPoint14(t *testing.T) { @@ -173,7 +251,7 @@ func TestRun_InitKeepUpgradeFlagIfLockFilePresentAndTFLessThanPoint14(t *testing Ok(t, err) RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ @@ -181,13 +259,15 @@ func TestRun_InitKeepUpgradeFlagIfLockFilePresentAndTFLessThanPoint14(t *testing RepoRelDir: ".", Log: logger, } - + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.13.0") iso := runtime.InitStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := iso.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) @@ -196,7 +276,7 @@ func TestRun_InitKeepUpgradeFlagIfLockFilePresentAndTFLessThanPoint14(t *testing Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") } func TestRun_InitExtraArgsDeDupe(t *testing.T) { @@ -240,7 +320,7 @@ func TestRun_InitExtraArgsDeDupe(t *testing.T) { for _, c := range cases { t.Run(c.description, func(t *testing.T) { - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ @@ -248,13 +328,15 @@ func TestRun_InitExtraArgsDeDupe(t *testing.T) { RepoRelDir: ".", Log: logger, } - + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.10.0") iso := runtime.InitStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := iso.Run(ctx, c.extraArgs, "/path", map[string]string(nil)) @@ -262,7 +344,7 @@ func TestRun_InitExtraArgsDeDupe(t *testing.T) { // When there is no error, should not return init output to PR. Equals(t, "", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", c.expectedArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", c.expectedArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") }) } } @@ -276,17 +358,19 @@ func TestRun_InitDeletesLockFileIfPresentAndNotTracked(t *testing.T) { Ok(t, err) RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() logger := logging.NewNoopLogger(t) - + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.14.0") iso := runtime.InitStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) ctx := command.ProjectContext{ @@ -300,7 +384,7 @@ func TestRun_InitDeletesLockFileIfPresentAndNotTracked(t *testing.T) { Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, repoDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, repoDir, expectedArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") } func runCmd(t *testing.T, dir string, name string, args ...string) string { diff --git a/server/core/runtime/mocks/mock_async_tfexec.go b/server/core/runtime/mocks/mock_async_tfexec.go index 662571ed0b..453c80012d 100644 --- a/server/core/runtime/mocks/mock_async_tfexec.go +++ b/server/core/runtime/mocks/mock_async_tfexec.go @@ -7,6 +7,7 @@ import ( go_version "github.com/hashicorp/go-version" pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/core/runtime/models" + terraform "github.com/runatlantis/atlantis/server/core/terraform" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" @@ -27,11 +28,11 @@ func NewMockAsyncTFExec(options ...pegomock.Option) *MockAsyncTFExec { func (mock *MockAsyncTFExec) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockAsyncTFExec) FailHandler() pegomock.FailHandler { return mock.fail } -func (mock *MockAsyncTFExec) RunCommandAsync(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) (chan<- string, <-chan models.Line) { +func (mock *MockAsyncTFExec) RunCommandAsync(ctx command.ProjectContext, path string, args []string, envs map[string]string, d terraform.Distribution, v *go_version.Version, workspace string) (chan<- string, <-chan models.Line) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockAsyncTFExec().") } - _params := []pegomock.Param{ctx, path, args, envs, v, workspace} + _params := []pegomock.Param{ctx, path, args, envs, d, v, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("RunCommandAsync", _params, []reflect.Type{reflect.TypeOf((*chan<- string)(nil)).Elem(), reflect.TypeOf((*<-chan models.Line)(nil)).Elem()}) var _ret0 chan<- string var _ret1 <-chan models.Line @@ -91,8 +92,8 @@ type VerifierMockAsyncTFExec struct { timeout time.Duration } -func (verifier *VerifierMockAsyncTFExec) RunCommandAsync(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) *MockAsyncTFExec_RunCommandAsync_OngoingVerification { - _params := []pegomock.Param{ctx, path, args, envs, v, workspace} +func (verifier *VerifierMockAsyncTFExec) RunCommandAsync(ctx command.ProjectContext, path string, args []string, envs map[string]string, d terraform.Distribution, v *go_version.Version, workspace string) *MockAsyncTFExec_RunCommandAsync_OngoingVerification { + _params := []pegomock.Param{ctx, path, args, envs, d, v, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunCommandAsync", _params, verifier.timeout) return &MockAsyncTFExec_RunCommandAsync_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } @@ -102,12 +103,12 @@ type MockAsyncTFExec_RunCommandAsync_OngoingVerification struct { methodInvocations []pegomock.MethodInvocation } -func (c *MockAsyncTFExec_RunCommandAsync_OngoingVerification) GetCapturedArguments() (command.ProjectContext, string, []string, map[string]string, *go_version.Version, string) { - ctx, path, args, envs, v, workspace := c.GetAllCapturedArguments() - return ctx[len(ctx)-1], path[len(path)-1], args[len(args)-1], envs[len(envs)-1], v[len(v)-1], workspace[len(workspace)-1] +func (c *MockAsyncTFExec_RunCommandAsync_OngoingVerification) GetCapturedArguments() (command.ProjectContext, string, []string, map[string]string, terraform.Distribution, *go_version.Version, string) { + ctx, path, args, envs, d, v, workspace := c.GetAllCapturedArguments() + return ctx[len(ctx)-1], path[len(path)-1], args[len(args)-1], envs[len(envs)-1], d[len(d)-1], v[len(v)-1], workspace[len(workspace)-1] } -func (c *MockAsyncTFExec_RunCommandAsync_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 [][]string, _param3 []map[string]string, _param4 []*go_version.Version, _param5 []string) { +func (c *MockAsyncTFExec_RunCommandAsync_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 [][]string, _param3 []map[string]string, _param4 []terraform.Distribution, _param5 []*go_version.Version, _param6 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { @@ -135,15 +136,21 @@ func (c *MockAsyncTFExec_RunCommandAsync_OngoingVerification) GetAllCapturedArgu } } if len(_params) > 4 { - _param4 = make([]*go_version.Version, len(c.methodInvocations)) + _param4 = make([]terraform.Distribution, len(c.methodInvocations)) for u, param := range _params[4] { - _param4[u] = param.(*go_version.Version) + _param4[u] = param.(terraform.Distribution) } } if len(_params) > 5 { - _param5 = make([]string, len(c.methodInvocations)) + _param5 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[5] { - _param5[u] = param.(string) + _param5[u] = param.(*go_version.Version) + } + } + if len(_params) > 6 { + _param6 = make([]string, len(c.methodInvocations)) + for u, param := range _params[6] { + _param6[u] = param.(string) } } } diff --git a/server/core/runtime/models/shell_command_runner.go b/server/core/runtime/models/shell_command_runner.go index 50b9f7760f..cd613bf450 100644 --- a/server/core/runtime/models/shell_command_runner.go +++ b/server/core/runtime/models/shell_command_runner.go @@ -10,8 +10,8 @@ import ( "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/config/valid" + "github.com/runatlantis/atlantis/server/core/terraform/ansi" "github.com/runatlantis/atlantis/server/events/command" - "github.com/runatlantis/atlantis/server/events/terraform/ansi" "github.com/runatlantis/atlantis/server/jobs" ) diff --git a/server/core/runtime/multienv_step_runner_test.go b/server/core/runtime/multienv_step_runner_test.go index 360adce3f5..326307fdea 100644 --- a/server/core/runtime/multienv_step_runner_test.go +++ b/server/core/runtime/multienv_step_runner_test.go @@ -7,7 +7,9 @@ import ( . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime" - "github.com/runatlantis/atlantis/server/core/terraform/mocks" + "github.com/runatlantis/atlantis/server/core/terraform" + terraformmocks "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" @@ -45,12 +47,15 @@ func TestMultiEnvStepRunner_Run(t *testing.T) { }, } RegisterMockTestingT(t) - tfClient := mocks.NewMockClient() + tfClient := tfclientmocks.NewMockClient() + mockDownloader := terraformmocks.NewMockDownloader() + tfDistribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, err := version.NewVersion("0.12.0") Ok(t, err) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() runStepRunner := runtime.RunStepRunner{ TerraformExecutor: tfClient, + DefaultTFDistribution: tfDistribution, DefaultTFVersion: tfVersion, ProjectCmdOutputHandler: projectCmdOutputHandler, } diff --git a/server/core/runtime/plan_step_runner.go b/server/core/runtime/plan_step_runner.go index 7d99dc26bf..b3fc491351 100644 --- a/server/core/runtime/plan_step_runner.go +++ b/server/core/runtime/plan_step_runner.go @@ -9,6 +9,7 @@ import ( version "github.com/hashicorp/go-version" "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" ) @@ -26,34 +27,40 @@ var ( ) type planStepRunner struct { - TerraformExecutor TerraformExec - DefaultTFVersion *version.Version - CommitStatusUpdater StatusUpdater - AsyncTFExec AsyncTFExec + TerraformExecutor TerraformExec + DefaultTFDistribution terraform.Distribution + DefaultTFVersion *version.Version + CommitStatusUpdater StatusUpdater + AsyncTFExec AsyncTFExec } -func NewPlanStepRunner(terraformExecutor TerraformExec, defaultTfVersion *version.Version, commitStatusUpdater StatusUpdater, asyncTFExec AsyncTFExec) Runner { +func NewPlanStepRunner(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version, commitStatusUpdater StatusUpdater, asyncTFExec AsyncTFExec) Runner { runner := &planStepRunner{ - TerraformExecutor: terraformExecutor, - DefaultTFVersion: defaultTfVersion, - CommitStatusUpdater: commitStatusUpdater, - AsyncTFExec: asyncTFExec, + TerraformExecutor: terraformExecutor, + DefaultTFDistribution: defaultTfDistribution, + DefaultTFVersion: defaultTfVersion, + CommitStatusUpdater: commitStatusUpdater, + AsyncTFExec: asyncTFExec, } - return NewWorkspaceStepRunnerDelegate(terraformExecutor, defaultTfVersion, runner) + return NewWorkspaceStepRunnerDelegate(terraformExecutor, defaultTfDistribution, defaultTfVersion, runner) } func (p *planStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { + tfDistribution := p.DefaultTFDistribution tfVersion := p.DefaultTFVersion + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } planFile := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)) planCmd := p.buildPlanCmd(ctx, extraArgs, path, tfVersion, planFile) - output, err := p.TerraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), planCmd, envs, tfVersion, ctx.Workspace) + output, err := p.TerraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), planCmd, envs, tfDistribution, tfVersion, ctx.Workspace) if p.isRemoteOpsErr(output, err) { ctx.Log.Debug("detected that this project is using TFE remote ops") - return p.remotePlan(ctx, extraArgs, path, tfVersion, planFile, envs) + return p.remotePlan(ctx, extraArgs, path, tfDistribution, tfVersion, planFile, envs) } if err != nil { return output, err @@ -72,14 +79,14 @@ func (p *planStepRunner) isRemoteOpsErr(output string, err error) bool { // remotePlan runs a terraform plan command compatible with TFE remote // operations. -func (p *planStepRunner) remotePlan(ctx command.ProjectContext, extraArgs []string, path string, tfVersion *version.Version, planFile string, envs map[string]string) (string, error) { +func (p *planStepRunner) remotePlan(ctx command.ProjectContext, extraArgs []string, path string, tfDistribution terraform.Distribution, tfVersion *version.Version, planFile string, envs map[string]string) (string, error) { argList := [][]string{ {"plan", "-input=false", "-refresh", "-no-color"}, extraArgs, ctx.EscapedCommentArgs, } args := p.flatten(argList) - output, err := p.runRemotePlan(ctx, args, path, tfVersion, envs) + output, err := p.runRemotePlan(ctx, args, path, tfDistribution, tfVersion, envs) if err != nil { return output, err } @@ -193,6 +200,7 @@ func (p *planStepRunner) runRemotePlan( ctx command.ProjectContext, cmdArgs []string, path string, + tfDistribution terraform.Distribution, tfVersion *version.Version, envs map[string]string) (string, error) { @@ -205,7 +213,7 @@ func (p *planStepRunner) runRemotePlan( // Start the async command execution. ctx.Log.Debug("starting async tf remote operation") - _, outCh := p.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), cmdArgs, envs, tfVersion, ctx.Workspace) + _, outCh := p.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), cmdArgs, envs, tfDistribution, tfVersion, ctx.Workspace) var lines []string nextLineIsRunURL := false var runURL string diff --git a/server/core/runtime/plan_step_runner_test.go b/server/core/runtime/plan_step_runner_test.go index f05336637c..6a16b03e3f 100644 --- a/server/core/runtime/plan_step_runner_test.go +++ b/server/core/runtime/plan_step_runner_test.go @@ -13,7 +13,9 @@ import ( "github.com/runatlantis/atlantis/server/core/runtime" runtimemocks "github.com/runatlantis/atlantis/server/core/runtime/mocks" runtimemodels "github.com/runatlantis/atlantis/server/core/runtime/models" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" @@ -24,7 +26,7 @@ import ( func TestRun_AddsEnvVarFile(t *testing.T) { // Test that if env/workspace.tfvars file exists we use -var-file option. RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() @@ -36,10 +38,12 @@ func TestRun_AddsEnvVarFile(t *testing.T) { err = os.WriteFile(envVarsFile, nil, 0600) Ok(t, err) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) // Using version >= 0.10 here so we don't expect any env commands. tfVersion, _ := version.NewVersion("0.10.0") logger := logging.NewNoopLogger(t) - s := runtime.NewPlanStepRunner(terraform, tfVersion, commitStatusUpdater, asyncTfExec) + s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) expPlanArgs := []string{"plan", "-input=false", @@ -78,14 +82,14 @@ func TestRun_AddsEnvVarFile(t *testing.T) { Name: "repo", }, } - When(terraform.RunCommandWithVersion(ctx, tmpDir, expPlanArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("output", nil) + When(terraform.RunCommandWithVersion(ctx, tmpDir, expPlanArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace")).ThenReturn("output", nil) output, err := s.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) // Verify that env select was never called since we're in version >= 0.10 - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, tmpDir, []string{"env", "select", "workspace"}, map[string]string(nil), tfVersion, "workspace") - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expPlanArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, tmpDir, []string{"env", "select", "workspace"}, map[string]string(nil), tfDistribution, tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expPlanArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") Equals(t, "output", output) } @@ -93,12 +97,14 @@ func TestRun_UsesDiffPathForProject(t *testing.T) { // Test that if running for a project, uses a different path for the plan // file. RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.10.0") logger := logging.NewNoopLogger(t) - s := runtime.NewPlanStepRunner(terraform, tfVersion, commitStatusUpdater, asyncTfExec) + s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) ctx := command.ProjectContext{ Log: logger, Workspace: "default", @@ -115,7 +121,7 @@ func TestRun_UsesDiffPathForProject(t *testing.T) { Name: "repo", }, } - When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("workspace\n", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfDistribution, tfVersion, "workspace")).ThenReturn("workspace\n", nil) expPlanArgs := []string{"plan", "-input=false", @@ -137,7 +143,7 @@ func TestRun_UsesDiffPathForProject(t *testing.T) { "comment", "args", } - When(terraform.RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "default")).ThenReturn("output", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfDistribution, tfVersion, "default")).ThenReturn("output", nil) output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) @@ -173,16 +179,19 @@ Terraform will perform the following actions: - aws_security_group_rule.allow_all ` RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.10.0") - s := runtime.NewPlanStepRunner(terraform, tfVersion, commitStatusUpdater, asyncTfExec) + s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) When(terraform.RunCommandWithVersion( Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), + Any[tf.Distribution](), Any[*version.Version](), Any[string]())). Then(func(params []Param) ReturnValues { @@ -223,11 +232,13 @@ Terraform will perform the following actions: // Test that even if there's an error, we get the returned output. func TestRun_OutputOnErr(t *testing.T) { RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.10.0") - s := runtime.NewPlanStepRunner(terraform, tfVersion, commitStatusUpdater, asyncTfExec) + s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) expOutput := "expected output" expErrMsg := "error!" When(terraform.RunCommandWithVersion( @@ -235,6 +246,7 @@ func TestRun_OutputOnErr(t *testing.T) { Any[string](), Any[[]string](), Any[map[string]string](), + Any[tf.Distribution](), Any[*version.Version](), Any[string]())). Then(func(params []Param) ReturnValues { @@ -287,7 +299,7 @@ func TestRun_NoOptionalVarsIn012(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() When(terraform.RunCommandWithVersion( @@ -295,11 +307,14 @@ func TestRun_NoOptionalVarsIn012(t *testing.T) { Any[string](), Any[[]string](), Any[map[string]string](), + Any[tf.Distribution](), Any[*version.Version](), Any[string]())).ThenReturn("output", nil) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion(c.tfVersion) - s := runtime.NewPlanStepRunner(terraform, tfVersion, commitStatusUpdater, asyncTfExec) + s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) ctx := command.ProjectContext{ Workspace: "default", RepoRelDir: ".", @@ -319,7 +334,7 @@ func TestRun_NoOptionalVarsIn012(t *testing.T) { Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "default") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfDistribution, tfVersion, "default") }) } @@ -385,11 +400,13 @@ locally at this time. }, } RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion(c.tfVersion) asyncTf := &remotePlanMock{} - s := runtime.NewPlanStepRunner(terraform, tfVersion, commitStatusUpdater, asyncTf) + s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTf) absProjectPath := t.TempDir() // First, terraform workspace gets run. @@ -398,6 +415,7 @@ locally at this time. absProjectPath, []string{"workspace", "show"}, map[string]string(nil), + tfDistribution, tfVersion, "default")).ThenReturn("default\n", nil) @@ -438,7 +456,7 @@ locally at this time. planErr := errors.New("exit status 1: err") planOutput := "\n" + c.remoteOpsErr asyncTf.LinesToSend = remotePlanOutput - When(terraform.RunCommandWithVersion(ctx, absProjectPath, expPlanArgs, map[string]string(nil), tfVersion, "default")). + When(terraform.RunCommandWithVersion(ctx, absProjectPath, expPlanArgs, map[string]string(nil), tfDistribution, tfVersion, "default")). ThenReturn(planOutput, planErr) output, err := s.Run(ctx, []string{"extra", "args"}, absProjectPath, map[string]string(nil)) @@ -536,6 +554,82 @@ Plan: 0 to add, 0 to change, 1 to destroy.`, output) } } +func TestPlanStepRunner_TestRun_UsesConfiguredDistribution(t *testing.T) { + RegisterMockTestingT(t) + + expPlanArgs := []string{ + "plan", + "-input=false", + "-refresh", + "-out", + fmt.Sprintf("%q", "/path/default.tfplan"), + "extra", + "args", + "comment", + "args", + } + + cases := []struct { + name string + tfVersion string + tfDistribution string + }{ + { + "stable version", + "0.12.0", + "terraform", + }, + { + "with prerelease", + "0.14.0-rc1", + "opentofu", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + terraform := tfclientmocks.NewMockClient() + commitStatusUpdater := runtimemocks.NewMockStatusUpdater() + asyncTfExec := runtimemocks.NewMockAsyncTFExec() + When(terraform.RunCommandWithVersion( + Any[command.ProjectContext](), + Any[string](), + Any[[]string](), + Any[map[string]string](), + Any[tf.Distribution](), + Any[*version.Version](), + Any[string]())).ThenReturn("output", nil) + + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + tfVersion, _ := version.NewVersion(c.tfVersion) + s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) + ctx := command.ProjectContext{ + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + TerraformDistribution: &c.tfDistribution, + } + + output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) + Ok(t, err) + Equals(t, "output", output) + + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(ctx), Eq("/path"), Eq(expPlanArgs), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq("default")) + }) + } + +} + type remotePlanMock struct { // LinesToSend will be sent on the channel. LinesToSend string @@ -543,7 +637,7 @@ type remotePlanMock struct { CalledArgs []string } -func (r *remotePlanMock) RunCommandAsync(_ command.ProjectContext, _ string, args []string, _ map[string]string, _ *version.Version, _ string) (chan<- string, <-chan runtimemodels.Line) { +func (r *remotePlanMock) RunCommandAsync(_ command.ProjectContext, _ string, args []string, _ map[string]string, _ tf.Distribution, _ *version.Version, _ string) (chan<- string, <-chan runtimemodels.Line) { r.CalledArgs = args in := make(chan string) out := make(chan runtimemodels.Line) diff --git a/server/core/runtime/plan_type_step_runner_delegate_test.go b/server/core/runtime/plan_type_step_runner_delegate_test.go index 286ae9ad40..db4be0ff03 100644 --- a/server/core/runtime/plan_type_step_runner_delegate_test.go +++ b/server/core/runtime/plan_type_step_runner_delegate_test.go @@ -153,3 +153,148 @@ func TestRunDelegate(t *testing.T) { }) } + +var openTofuPlanFileContents = ` +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + - destroy + +OpenTofu will perform the following actions: + + - null_resource.hi[1] + + +Plan: 0 to add, 0 to change, 1 to destroy.` + +func TestRunDelegate_UsesConfiguredDistribution(t *testing.T) { + + RegisterMockTestingT(t) + + mockDefaultRunner := mocks.NewMockRunner() + mockRemoteRunner := mocks.NewMockRunner() + + subject := &planTypeStepRunnerDelegate{ + defaultRunner: mockDefaultRunner, + remotePlanRunner: mockRemoteRunner, + } + + tfDistribution := "opentofu" + tfVersion, _ := version.NewVersion("1.7.0") + + t.Run("Remote Runner Success", func(t *testing.T) { + tmpDir := t.TempDir() + planPath := filepath.Join(tmpDir, "workspace.tfplan") + err := os.WriteFile(planPath, []byte("Atlantis: this plan was created by remote ops\n"+openTofuPlanFileContents), 0600) + Ok(t, err) + + ctx := command.ProjectContext{ + Workspace: "workspace", + RepoRelDir: ".", + EscapedCommentArgs: []string{"comment", "args"}, + TerraformDistribution: &tfDistribution, + TerraformVersion: tfVersion, + } + extraArgs := []string{"extra", "args"} + envs := map[string]string{} + + expectedOut := "some random output" + + When(mockRemoteRunner.Run(ctx, extraArgs, tmpDir, envs)).ThenReturn(expectedOut, nil) + + output, err := subject.Run(ctx, extraArgs, tmpDir, envs) + + mockDefaultRunner.VerifyWasCalled(Never()) + + Equals(t, expectedOut, output) + Ok(t, err) + + }) + + t.Run("Remote Runner Failure", func(t *testing.T) { + tmpDir := t.TempDir() + planPath := filepath.Join(tmpDir, "workspace.tfplan") + err := os.WriteFile(planPath, []byte("Atlantis: this plan was created by remote ops\n"+openTofuPlanFileContents), 0600) + Ok(t, err) + + ctx := command.ProjectContext{ + Workspace: "workspace", + RepoRelDir: ".", + EscapedCommentArgs: []string{"comment", "args"}, + TerraformDistribution: &tfDistribution, + TerraformVersion: tfVersion, + } + extraArgs := []string{"extra", "args"} + envs := map[string]string{} + + expectedOut := "some random output" + + When(mockRemoteRunner.Run(ctx, extraArgs, tmpDir, envs)).ThenReturn(expectedOut, errors.New("err")) + + output, err := subject.Run(ctx, extraArgs, tmpDir, envs) + + mockDefaultRunner.VerifyWasCalled(Never()) + + Equals(t, expectedOut, output) + Assert(t, err != nil, "err should not be nil") + + }) + + t.Run("Local Runner Success", func(t *testing.T) { + tmpDir := t.TempDir() + planPath := filepath.Join(tmpDir, "workspace.tfplan") + err := os.WriteFile(planPath, []byte(openTofuPlanFileContents), 0600) + Ok(t, err) + + ctx := command.ProjectContext{ + Workspace: "workspace", + RepoRelDir: ".", + EscapedCommentArgs: []string{"comment", "args"}, + TerraformDistribution: &tfDistribution, + TerraformVersion: tfVersion, + } + extraArgs := []string{"extra", "args"} + envs := map[string]string{} + + expectedOut := "some random output" + + When(mockDefaultRunner.Run(ctx, extraArgs, tmpDir, envs)).ThenReturn(expectedOut, nil) + + output, err := subject.Run(ctx, extraArgs, tmpDir, envs) + + mockRemoteRunner.VerifyWasCalled(Never()) + + Equals(t, expectedOut, output) + Ok(t, err) + + }) + + t.Run("Local Runner Failure", func(t *testing.T) { + tmpDir := t.TempDir() + planPath := filepath.Join(tmpDir, "workspace.tfplan") + err := os.WriteFile(planPath, []byte(openTofuPlanFileContents), 0600) + Ok(t, err) + + ctx := command.ProjectContext{ + Workspace: "workspace", + RepoRelDir: ".", + EscapedCommentArgs: []string{"comment", "args"}, + TerraformDistribution: &tfDistribution, + TerraformVersion: tfVersion, + } + extraArgs := []string{"extra", "args"} + envs := map[string]string{} + + expectedOut := "some random output" + + When(mockDefaultRunner.Run(ctx, extraArgs, tmpDir, envs)).ThenReturn(expectedOut, errors.New("err")) + + output, err := subject.Run(ctx, extraArgs, tmpDir, envs) + + mockRemoteRunner.VerifyWasCalled(Never()) + + Equals(t, expectedOut, output) + Assert(t, err != nil, "err should not be nil") + + }) + +} diff --git a/server/core/runtime/policy_check_step_runner.go b/server/core/runtime/policy_check_step_runner.go index 98e4408bcb..2987875f18 100644 --- a/server/core/runtime/policy_check_step_runner.go +++ b/server/core/runtime/policy_check_step_runner.go @@ -3,6 +3,7 @@ package runtime import ( "github.com/hashicorp/go-version" "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" ) @@ -13,7 +14,7 @@ type policyCheckStepRunner struct { } // NewPolicyCheckStepRunner creates a new step runner from an executor workflow -func NewPolicyCheckStepRunner(defaultTfVersion *version.Version, executorWorkflow VersionedExecutorWorkflow) (Runner, error) { +func NewPolicyCheckStepRunner(defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version, executorWorkflow VersionedExecutorWorkflow) (Runner, error) { policyCheckStepRunner := &policyCheckStepRunner{ versionEnsurer: executorWorkflow, executor: executorWorkflow, diff --git a/server/core/runtime/post_workflow_hook_runner_test.go b/server/core/runtime/post_workflow_hook_runner_test.go index 8bab373502..3a7d9499d0 100644 --- a/server/core/runtime/post_workflow_hook_runner_test.go +++ b/server/core/runtime/post_workflow_hook_runner_test.go @@ -8,7 +8,8 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/runtime" - "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tf "github.com/runatlantis/atlantis/server/core/terraform" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/models" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" "github.com/runatlantis/atlantis/server/logging" @@ -142,8 +143,8 @@ func TestPostWorkflowHookRunner_Run(t *testing.T) { Ok(t, err) RegisterMockTestingT(t) - terraform := mocks.NewMockClient() - When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[*version.Version]())). + terraform := tfclientmocks.NewMockClient() + When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[tf.Distribution](), Any[*version.Version]())). ThenReturn(nil) logger := logging.NewNoopLogger(t) diff --git a/server/core/runtime/pre_workflow_hook_runner_test.go b/server/core/runtime/pre_workflow_hook_runner_test.go index 40133c10a5..b621fa3e07 100644 --- a/server/core/runtime/pre_workflow_hook_runner_test.go +++ b/server/core/runtime/pre_workflow_hook_runner_test.go @@ -9,7 +9,8 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/runtime" - "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tf "github.com/runatlantis/atlantis/server/core/terraform" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/models" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" "github.com/runatlantis/atlantis/server/logging" @@ -162,8 +163,8 @@ func TestPreWorkflowHookRunner_Run(t *testing.T) { Ok(t, err) RegisterMockTestingT(t) - terraform := mocks.NewMockClient() - When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[*version.Version]())). + terraform := tfclientmocks.NewMockClient() + When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[tf.Distribution](), Any[*version.Version]())). ThenReturn(nil) logger := logging.NewNoopLogger(t) diff --git a/server/core/runtime/run_step_runner.go b/server/core/runtime/run_step_runner.go index 76629ba460..5fa32896d7 100644 --- a/server/core/runtime/run_step_runner.go +++ b/server/core/runtime/run_step_runner.go @@ -9,14 +9,16 @@ import ( "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime/models" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/jobs" ) // RunStepRunner runs custom commands. type RunStepRunner struct { - TerraformExecutor TerraformExec - DefaultTFVersion *version.Version + TerraformExecutor TerraformExec + DefaultTFDistribution terraform.Distribution + DefaultTFVersion *version.Version // TerraformBinDir is the directory where Atlantis downloads Terraform binaries. TerraformBinDir string ProjectCmdOutputHandler jobs.ProjectCommandOutputHandler @@ -31,12 +33,16 @@ func (r *RunStepRunner) Run( streamOutput bool, postProcessOutput valid.PostProcessRunOutputOption, ) (string, error) { + tfDistribution := r.DefaultTFDistribution tfVersion := r.DefaultTFVersion + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } - err := r.TerraformExecutor.EnsureVersion(ctx.Log, tfVersion) + err := r.TerraformExecutor.EnsureVersion(ctx.Log, tfDistribution, tfVersion) if err != nil { err = fmt.Errorf("%s: Downloading terraform Version %s", err, tfVersion.String()) ctx.Log.Debug("error: %s", err) @@ -45,27 +51,28 @@ func (r *RunStepRunner) Run( baseEnvVars := os.Environ() customEnvVars := map[string]string{ - "ATLANTIS_TERRAFORM_VERSION": tfVersion.String(), - "BASE_BRANCH_NAME": ctx.Pull.BaseBranch, - "BASE_REPO_NAME": ctx.BaseRepo.Name, - "BASE_REPO_OWNER": ctx.BaseRepo.Owner, - "COMMENT_ARGS": strings.Join(ctx.EscapedCommentArgs, ","), - "DIR": path, - "HEAD_BRANCH_NAME": ctx.Pull.HeadBranch, - "HEAD_COMMIT": ctx.Pull.HeadCommit, - "HEAD_REPO_NAME": ctx.HeadRepo.Name, - "HEAD_REPO_OWNER": ctx.HeadRepo.Owner, - "PATH": fmt.Sprintf("%s:%s", os.Getenv("PATH"), r.TerraformBinDir), - "PLANFILE": filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)), - "SHOWFILE": filepath.Join(path, ctx.GetShowResultFileName()), - "POLICYCHECKFILE": filepath.Join(path, ctx.GetPolicyCheckResultFileName()), - "PROJECT_NAME": ctx.ProjectName, - "PULL_AUTHOR": ctx.Pull.Author, - "PULL_NUM": fmt.Sprintf("%d", ctx.Pull.Num), - "PULL_URL": ctx.Pull.URL, - "REPO_REL_DIR": ctx.RepoRelDir, - "USER_NAME": ctx.User.Username, - "WORKSPACE": ctx.Workspace, + "ATLANTIS_TERRAFORM_DISTRIBUTION": tfDistribution.BinName(), + "ATLANTIS_TERRAFORM_VERSION": tfVersion.String(), + "BASE_BRANCH_NAME": ctx.Pull.BaseBranch, + "BASE_REPO_NAME": ctx.BaseRepo.Name, + "BASE_REPO_OWNER": ctx.BaseRepo.Owner, + "COMMENT_ARGS": strings.Join(ctx.EscapedCommentArgs, ","), + "DIR": path, + "HEAD_BRANCH_NAME": ctx.Pull.HeadBranch, + "HEAD_COMMIT": ctx.Pull.HeadCommit, + "HEAD_REPO_NAME": ctx.HeadRepo.Name, + "HEAD_REPO_OWNER": ctx.HeadRepo.Owner, + "PATH": fmt.Sprintf("%s:%s", os.Getenv("PATH"), r.TerraformBinDir), + "PLANFILE": filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)), + "SHOWFILE": filepath.Join(path, ctx.GetShowResultFileName()), + "POLICYCHECKFILE": filepath.Join(path, ctx.GetPolicyCheckResultFileName()), + "PROJECT_NAME": ctx.ProjectName, + "PULL_AUTHOR": ctx.Pull.Author, + "PULL_NUM": fmt.Sprintf("%d", ctx.Pull.Num), + "PULL_URL": ctx.Pull.URL, + "REPO_REL_DIR": ctx.RepoRelDir, + "USER_NAME": ctx.User.Username, + "WORKSPACE": ctx.Workspace, } finalEnvVars := baseEnvVars @@ -88,9 +95,10 @@ func (r *RunStepRunner) Run( err = fmt.Errorf("%s: running %q in %q: \n%s", err, command, path, output) if !ctx.CustomPolicyCheck { ctx.Log.Debug("error: %s", err) - return "", err + } else { + ctx.Log.Debug("Treating custom policy tool error exit code as a policy failure. Error output: %s", err) } - ctx.Log.Debug("Treating custom policy tool error exit code as a policy failure. Error output: %s", err) + return "", err } switch postProcessOutput { diff --git a/server/core/runtime/run_step_runner_test.go b/server/core/runtime/run_step_runner_test.go index 4672fa2bb0..51289f8c49 100644 --- a/server/core/runtime/run_step_runner_test.go +++ b/server/core/runtime/run_step_runner_test.go @@ -10,7 +10,9 @@ import ( . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" @@ -20,11 +22,12 @@ import ( func TestRunStepRunner_Run(t *testing.T) { cases := []struct { - Command string - ProjectName string - ExpOut string - ExpErr string - Version string + Command string + ProjectName string + ExpOut string + ExpErr string + Version string + Distribution string }{ { Command: "", @@ -69,6 +72,18 @@ func TestRunStepRunner_Run(t *testing.T) { ProjectName: "my/project/name", ExpOut: "workspace=myworkspace version=0.11.0 dir=$DIR planfile=$DIR/my::project::name-myworkspace.tfplan showfile=$DIR/my::project::name-myworkspace.json project=my/project/name\n", }, + { + Command: "echo distribution=$ATLANTIS_TERRAFORM_DISTRIBUTION", + ProjectName: "my/project/name", + ExpOut: "distribution=terraform\n", + Distribution: "terraform", + }, + { + Command: "echo distribution=$ATLANTIS_TERRAFORM_DISTRIBUTION", + ProjectName: "my/project/name", + ExpOut: "distribution=tofu\n", + Distribution: "opentofu", + }, { Command: "echo base_repo_name=$BASE_REPO_NAME base_repo_owner=$BASE_REPO_OWNER head_repo_name=$HEAD_REPO_NAME head_repo_owner=$HEAD_REPO_OWNER head_branch_name=$HEAD_BRANCH_NAME head_commit=$HEAD_COMMIT base_branch_name=$BASE_BRANCH_NAME pull_num=$PULL_NUM pull_url=$PULL_URL pull_author=$PULL_AUTHOR repo_rel_dir=$REPO_REL_DIR", ExpOut: "base_repo_name=basename base_repo_owner=baseowner head_repo_name=headname head_repo_owner=headowner head_branch_name=add-feat head_commit=12345abcdef base_branch_name=main pull_num=2 pull_url=https://github.com/runatlantis/atlantis/pull/2 pull_author=acme repo_rel_dir=mydir\n", @@ -85,81 +100,90 @@ func TestRunStepRunner_Run(t *testing.T) { ExpOut: "args=-target=resource1,-target=resource2\n", }, } + for _, customPolicyCheck := range []bool{false, true} { + for _, c := range cases { + var projVersion *version.Version + var err error - for _, c := range cases { - - var projVersion *version.Version - var err error + projVersion, err = version.NewVersion("v0.11.0") - projVersion, err = version.NewVersion("v0.11.0") + if c.Version != "" { + projVersion, err = version.NewVersion(c.Version) + Ok(t, err) + } - if c.Version != "" { - projVersion, err = version.NewVersion(c.Version) Ok(t, err) - } - Ok(t, err) + projTFDistribution := "terraform" + if c.Distribution != "" { + projTFDistribution = c.Distribution + } - defaultVersion, _ := version.NewVersion("0.8") + defaultVersion, _ := version.NewVersion("0.8") - RegisterMockTestingT(t) - terraform := mocks.NewMockClient() - When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[*version.Version]())). - ThenReturn(nil) + RegisterMockTestingT(t) + terraform := tfclientmocks.NewMockClient() + defaultDistribution := tf.NewDistributionTerraformWithDownloader(mocks.NewMockDownloader()) + When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[tf.Distribution](), Any[*version.Version]())). + ThenReturn(nil) - logger := logging.NewNoopLogger(t) - projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() - tmpDir := t.TempDir() + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() + tmpDir := t.TempDir() - r := runtime.RunStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: defaultVersion, - TerraformBinDir: "/bin/dir", - ProjectCmdOutputHandler: projectCmdOutputHandler, - } - t.Run(c.Command, func(t *testing.T) { - ctx := command.ProjectContext{ - BaseRepo: models.Repo{ - Name: "basename", - Owner: "baseowner", - }, - HeadRepo: models.Repo{ - Name: "headname", - Owner: "headowner", - }, - Pull: models.PullRequest{ - Num: 2, - URL: "https://github.com/runatlantis/atlantis/pull/2", - HeadBranch: "add-feat", - HeadCommit: "12345abcdef", - BaseBranch: "main", - Author: "acme", - }, - User: models.User{ - Username: "acme-user", - }, - Log: logger, - Workspace: "myworkspace", - RepoRelDir: "mydir", - TerraformVersion: projVersion, - ProjectName: c.ProjectName, - EscapedCommentArgs: []string{"-target=resource1", "-target=resource2"}, + r := runtime.RunStepRunner{ + TerraformExecutor: terraform, + DefaultTFDistribution: defaultDistribution, + DefaultTFVersion: defaultVersion, + TerraformBinDir: "/bin/dir", + ProjectCmdOutputHandler: projectCmdOutputHandler, } - out, err := r.Run(ctx, nil, c.Command, tmpDir, map[string]string{"test": "var"}, true, valid.PostProcessRunOutputShow) - if c.ExpErr != "" { - ErrContains(t, c.ExpErr, err) - return - } - Ok(t, err) - // Replace $DIR in the exp with the actual temp dir. We do this - // here because when constructing the cases we don't yet know the - // temp dir. - expOut := strings.Replace(c.ExpOut, "$DIR", tmpDir, -1) - Equals(t, expOut, out) + t.Run(fmt.Sprintf("%s_CustomPolicyCheck=%v", c.Command, customPolicyCheck), func(t *testing.T) { + ctx := command.ProjectContext{ + BaseRepo: models.Repo{ + Name: "basename", + Owner: "baseowner", + }, + HeadRepo: models.Repo{ + Name: "headname", + Owner: "headowner", + }, + Pull: models.PullRequest{ + Num: 2, + URL: "https://github.com/runatlantis/atlantis/pull/2", + HeadBranch: "add-feat", + HeadCommit: "12345abcdef", + BaseBranch: "main", + Author: "acme", + }, + User: models.User{ + Username: "acme-user", + }, + Log: logger, + Workspace: "myworkspace", + RepoRelDir: "mydir", + TerraformDistribution: &projTFDistribution, + TerraformVersion: projVersion, + ProjectName: c.ProjectName, + EscapedCommentArgs: []string{"-target=resource1", "-target=resource2"}, + CustomPolicyCheck: customPolicyCheck, + } + out, err := r.Run(ctx, nil, c.Command, tmpDir, map[string]string{"test": "var"}, true, valid.PostProcessRunOutputShow) + if c.ExpErr != "" { + ErrContains(t, c.ExpErr, err) + return + } + Ok(t, err) + // Replace $DIR in the exp with the actual temp dir. We do this + // here because when constructing the cases we don't yet know the + // temp dir. + expOut := strings.Replace(c.ExpOut, "$DIR", tmpDir, -1) + Equals(t, expOut, out) - terraform.VerifyWasCalledOnce().EnsureVersion(logger, projVersion) - terraform.VerifyWasCalled(Never()).EnsureVersion(logger, defaultVersion) + terraform.VerifyWasCalledOnce().EnsureVersion(Eq(logger), NotEq(defaultDistribution), Eq(projVersion)) + terraform.VerifyWasCalled(Never()).EnsureVersion(Eq(logger), Eq(defaultDistribution), Eq(defaultVersion)) - }) + }) + } } } diff --git a/server/core/runtime/runtime.go b/server/core/runtime/runtime.go index 52fc5180eb..35e571262b 100644 --- a/server/core/runtime/runtime.go +++ b/server/core/runtime/runtime.go @@ -11,6 +11,7 @@ import ( version "github.com/hashicorp/go-version" "github.com/pkg/errors" runtimemodels "github.com/runatlantis/atlantis/server/core/runtime/models" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" @@ -26,8 +27,8 @@ const ( // TerraformExec brings the interface from TerraformClient into this package // without causing circular imports. type TerraformExec interface { - RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *version.Version, workspace string) (string, error) - EnsureVersion(log logging.SimpleLogging, v *version.Version) error + RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, d terraform.Distribution, v *version.Version, workspace string) (string, error) + EnsureVersion(log logging.SimpleLogging, d terraform.Distribution, v *version.Version) error } // AsyncTFExec brings the interface from TerraformClient into this package @@ -43,7 +44,7 @@ type AsyncTFExec interface { // Callers can use the input channel to pass stdin input to the command. // If any error is passed on the out channel, there will be no // further output (so callers are free to exit). - RunCommandAsync(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *version.Version, workspace string) (chan<- string, <-chan runtimemodels.Line) + RunCommandAsync(ctx command.ProjectContext, path string, args []string, envs map[string]string, d terraform.Distribution, v *version.Version, workspace string) (chan<- string, <-chan runtimemodels.Line) } // StatusUpdater brings the interface from CommitStatusUpdater into this package diff --git a/server/core/runtime/show_step_runner.go b/server/core/runtime/show_step_runner.go index ba89479b56..ed346bc184 100644 --- a/server/core/runtime/show_step_runner.go +++ b/server/core/runtime/show_step_runner.go @@ -6,15 +6,17 @@ import ( "github.com/hashicorp/go-version" "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" ) const minimumShowTfVersion string = "0.12.0" -func NewShowStepRunner(executor TerraformExec, defaultTFVersion *version.Version) (Runner, error) { +func NewShowStepRunner(executor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTFVersion *version.Version) (Runner, error) { showStepRunner := &showStepRunner{ - terraformExecutor: executor, - defaultTFVersion: defaultTFVersion, + terraformExecutor: executor, + defaultTfDistribution: defaultTfDistribution, + defaultTFVersion: defaultTFVersion, } remotePlanRunner := NullRunner{} runner := NewPlanTypeStepRunnerDelegate(showStepRunner, remotePlanRunner) @@ -23,12 +25,17 @@ func NewShowStepRunner(executor TerraformExec, defaultTFVersion *version.Version // showStepRunner runs terraform show on an existing plan file and outputs it to a json file type showStepRunner struct { - terraformExecutor TerraformExec - defaultTFVersion *version.Version + terraformExecutor TerraformExec + defaultTfDistribution terraform.Distribution + defaultTFVersion *version.Version } func (p *showStepRunner) Run(ctx command.ProjectContext, _ []string, path string, envs map[string]string) (string, error) { + tfDistribution := p.defaultTfDistribution tfVersion := p.defaultTFVersion + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } @@ -41,6 +48,7 @@ func (p *showStepRunner) Run(ctx command.ProjectContext, _ []string, path string path, []string{"show", "-json", filepath.Clean(planFile)}, envs, + tfDistribution, tfVersion, ctx.Workspace, ) diff --git a/server/core/runtime/show_step_runner_test.go b/server/core/runtime/show_step_runner_test.go index 9803efb9ff..8c390014ad 100644 --- a/server/core/runtime/show_step_runner_test.go +++ b/server/core/runtime/show_step_runner_test.go @@ -9,7 +9,9 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" @@ -20,6 +22,8 @@ func TestShowStepRunnner(t *testing.T) { path := t.TempDir() resultPath := filepath.Join(path, "test-default.json") envs := map[string]string{"key": "val"} + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.12") context := command.ProjectContext{ Workspace: "default", @@ -29,17 +33,18 @@ func TestShowStepRunnner(t *testing.T) { RegisterMockTestingT(t) - mockExecutor := mocks.NewMockClient() + mockExecutor := tfclientmocks.NewMockClient() subject := showStepRunner{ - terraformExecutor: mockExecutor, - defaultTFVersion: tfVersion, + terraformExecutor: mockExecutor, + defaultTfDistribution: tfDistribution, + defaultTFVersion: tfVersion, } t.Run("success", func(t *testing.T) { When(mockExecutor.RunCommandWithVersion( - context, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfVersion, context.Workspace, + context, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfDistribution, tfVersion, context.Workspace, )).ThenReturn("success", nil) r, err := subject.Run(context, []string{}, path, envs) @@ -57,6 +62,8 @@ func TestShowStepRunnner(t *testing.T) { t.Run("success w/ version override", func(t *testing.T) { v, _ := version.NewVersion("0.13.0") + mockDownloader := mocks.NewMockDownloader() + d := tf.NewDistributionTerraformWithDownloader(mockDownloader) contextWithVersionOverride := command.ProjectContext{ Workspace: "default", @@ -66,7 +73,7 @@ func TestShowStepRunnner(t *testing.T) { } When(mockExecutor.RunCommandWithVersion( - contextWithVersionOverride, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, v, context.Workspace, + contextWithVersionOverride, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, d, v, context.Workspace, )).ThenReturn("success", nil) r, err := subject.Run(contextWithVersionOverride, []string{}, path, envs) @@ -81,9 +88,39 @@ func TestShowStepRunnner(t *testing.T) { }) + t.Run("success w/ distribution override", func(t *testing.T) { + + v, _ := version.NewVersion("0.13.0") + mockDownloader := mocks.NewMockDownloader() + d := tf.NewDistributionTerraformWithDownloader(mockDownloader) + projTFDistribution := "opentofu" + + contextWithDistributionOverride := command.ProjectContext{ + Workspace: "default", + ProjectName: "test", + Log: logger, + TerraformDistribution: &projTFDistribution, + } + + When(mockExecutor.RunCommandWithVersion( + Eq(contextWithDistributionOverride), Eq(path), Eq([]string{"show", "-json", filepath.Join(path, "test-default.tfplan")}), Eq(envs), NotEq(d), NotEq(v), Eq(context.Workspace), + )).ThenReturn("success", nil) + + r, err := subject.Run(contextWithDistributionOverride, []string{}, path, envs) + + Ok(t, err) + + actual, _ := os.ReadFile(resultPath) + + actualStr := string(actual) + Assert(t, actualStr == "success", "got expected result") + Assert(t, r == "success", "returned expected result") + + }) + t.Run("failure running command", func(t *testing.T) { When(mockExecutor.RunCommandWithVersion( - context, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfVersion, context.Workspace, + context, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfDistribution, tfVersion, context.Workspace, )).ThenReturn("success", errors.New("error")) _, err := subject.Run(context, []string{}, path, envs) diff --git a/server/core/runtime/state_rm_step_runner.go b/server/core/runtime/state_rm_step_runner.go index 3b4a08f102..42af97c006 100644 --- a/server/core/runtime/state_rm_step_runner.go +++ b/server/core/runtime/state_rm_step_runner.go @@ -5,25 +5,32 @@ import ( "path/filepath" version "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/utils" ) type stateRmStepRunner struct { - terraformExecutor TerraformExec - defaultTFVersion *version.Version + terraformExecutor TerraformExec + defaultTFDistribution terraform.Distribution + defaultTFVersion *version.Version } -func NewStateRmStepRunner(terraformExecutor TerraformExec, defaultTfVersion *version.Version) Runner { +func NewStateRmStepRunner(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version) Runner { runner := &stateRmStepRunner{ - terraformExecutor: terraformExecutor, - defaultTFVersion: defaultTfVersion, + terraformExecutor: terraformExecutor, + defaultTFDistribution: defaultTfDistribution, + defaultTFVersion: defaultTfVersion, } - return NewWorkspaceStepRunnerDelegate(terraformExecutor, defaultTfVersion, runner) + return NewWorkspaceStepRunnerDelegate(terraformExecutor, defaultTfDistribution, defaultTfVersion, runner) } func (p *stateRmStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { + tfDistribution := p.defaultTFDistribution tfVersion := p.defaultTFVersion + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } @@ -31,7 +38,7 @@ func (p *stateRmStepRunner) Run(ctx command.ProjectContext, extraArgs []string, stateRmCmd := []string{"state", "rm"} stateRmCmd = append(stateRmCmd, extraArgs...) stateRmCmd = append(stateRmCmd, ctx.EscapedCommentArgs...) - out, err := p.terraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), stateRmCmd, envs, tfVersion, ctx.Workspace) + out, err := p.terraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), stateRmCmd, envs, tfDistribution, tfVersion, ctx.Workspace) // If the state rm was successful and a plan file exists, delete the plan. planPath := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)) diff --git a/server/core/runtime/state_rm_step_runner_test.go b/server/core/runtime/state_rm_step_runner_test.go index df5e1036e8..194879f2bd 100644 --- a/server/core/runtime/state_rm_step_runner_test.go +++ b/server/core/runtime/state_rm_step_runner_test.go @@ -8,7 +8,9 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" @@ -29,17 +31,19 @@ func TestStateRmStepRunner_Run_Success(t *testing.T) { } RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() tfVersion, _ := version.NewVersion("0.15.0") - s := NewStateRmStepRunner(terraform, tfVersion) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + s := NewStateRmStepRunner(terraform, tfDistribution, tfVersion) - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) commands := []string{"state", "rm", "-lock=false", "addr1", "addr2", "addr3"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfVersion, "default") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfDistribution, tfVersion, "default") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } @@ -59,23 +63,67 @@ func TestStateRmStepRunner_Run_Workspace(t *testing.T) { } RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() tfVersion, _ := version.NewVersion("0.15.0") - s := NewStateRmStepRunner(terraform, tfVersion) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + s := NewStateRmStepRunner(terraform, tfDistribution, tfVersion) - When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[*version.Version](), Any[string]())). + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). ThenReturn("output", nil) output, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) // switch workspace - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "show"}, map[string]string(nil), tfVersion, workspace) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "select", workspace}, map[string]string(nil), tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "show"}, map[string]string(nil), tfDistribution, tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"workspace", "select", workspace}, map[string]string(nil), tfDistribution, tfVersion, workspace) // exec state rm commands := []string{"state", "rm", "-lock=false", "addr1", "addr2", "addr3"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, commands, map[string]string(nil), tfDistribution, tfVersion, workspace) + + _, err = os.Stat(planPath) + Assert(t, os.IsNotExist(err), "planfile should be deleted") +} + +func TestStateRmStepRunner_Run_UsesConfiguredDistribution(t *testing.T) { + logger := logging.NewNoopLogger(t) + workspace := "something" + tmpDir := t.TempDir() + planPath := filepath.Join(tmpDir, fmt.Sprintf("%s.tfplan", workspace)) + err := os.WriteFile(planPath, nil, 0600) + Ok(t, err) + + projTFDistribution := "opentofu" + + context := command.ProjectContext{ + Log: logger, + EscapedCommentArgs: []string{"-lock=false", "addr1", "addr2", "addr3"}, + Workspace: workspace, + TerraformDistribution: &projTFDistribution, + } + + RegisterMockTestingT(t) + terraform := tfclientmocks.NewMockClient() + tfVersion, _ := version.NewVersion("0.15.0") + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + s := NewStateRmStepRunner(terraform, tfDistribution, tfVersion) + + When(terraform.RunCommandWithVersion(Any[command.ProjectContext](), Any[string](), Any[[]string](), Any[map[string]string](), Any[tf.Distribution](), Any[*version.Version](), Any[string]())). + ThenReturn("output", nil) + output, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) + Ok(t, err) + Equals(t, "output", output) + + // switch workspace + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(context), Eq(tmpDir), Eq([]string{"workspace", "show"}), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq(workspace)) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(context), Eq(tmpDir), Eq([]string{"workspace", "select", workspace}), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq(workspace)) + + // exec state rm + commands := []string{"state", "rm", "-lock=false", "addr1", "addr2", "addr3"} + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(context), Eq(tmpDir), Eq(commands), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq(workspace)) _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") diff --git a/server/core/runtime/version_step_runner.go b/server/core/runtime/version_step_runner.go index c75c5396fb..db1f525743 100644 --- a/server/core/runtime/version_step_runner.go +++ b/server/core/runtime/version_step_runner.go @@ -4,22 +4,28 @@ import ( "path/filepath" "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" ) // VersionStepRunner runs a version command given a ctx type VersionStepRunner struct { - TerraformExecutor TerraformExec - DefaultTFVersion *version.Version + TerraformExecutor TerraformExec + DefaultTFDistribution terraform.Distribution + DefaultTFVersion *version.Version } // Run ensures a given version for the executable, builds the args from the project context and then runs executable returning the result func (v *VersionStepRunner) Run(ctx command.ProjectContext, _ []string, path string, envs map[string]string) (string, error) { + tfDistribution := v.DefaultTFDistribution tfVersion := v.DefaultTFVersion + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } versionCmd := []string{"version"} - return v.TerraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), versionCmd, envs, tfVersion, ctx.Workspace) + return v.TerraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), versionCmd, envs, tfDistribution, tfVersion, ctx.Workspace) } diff --git a/server/core/runtime/version_step_runner_test.go b/server/core/runtime/version_step_runner_test.go index 55c4fc05f4..45bf890fab 100644 --- a/server/core/runtime/version_step_runner_test.go +++ b/server/core/runtime/version_step_runner_test.go @@ -5,7 +5,9 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" @@ -33,18 +35,62 @@ func TestRunVersionStep(t *testing.T) { }, } - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.15.0") tmpDir := t.TempDir() s := &VersionStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, } t.Run("ensure runs", func(t *testing.T) { _, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"version"}, map[string]string(nil), tfVersion, "default") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"version"}, map[string]string(nil), tfDistribution, tfVersion, "default") + Ok(t, err) + }) +} + +func TestVersionStepRunner_Run_UsesConfiguredDistribution(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + workspace := "default" + projTFDistribution := "opentofu" + context := command.ProjectContext{ + Log: logger, + EscapedCommentArgs: []string{"comment", "args"}, + Workspace: workspace, + RepoRelDir: ".", + User: models.User{Username: "username"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + TerraformDistribution: &projTFDistribution, + } + + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + tfVersion, _ := version.NewVersion("0.15.0") + tmpDir := t.TempDir() + + s := &VersionStepRunner{ + TerraformExecutor: terraform, + DefaultTFDistribution: tfDistribution, + DefaultTFVersion: tfVersion, + } + + t.Run("ensure runs", func(t *testing.T) { + _, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(context), Eq(tmpDir), Eq([]string{"version"}), Eq(map[string]string(nil)), NotEq(tfDistribution), Eq(tfVersion), Eq("default")) Ok(t, err) }) } diff --git a/server/core/runtime/workspace_step_runner_delegate.go b/server/core/runtime/workspace_step_runner_delegate.go index 9d77db44d0..5628a6a351 100644 --- a/server/core/runtime/workspace_step_runner_delegate.go +++ b/server/core/runtime/workspace_step_runner_delegate.go @@ -5,33 +5,40 @@ import ( "strings" "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" ) // workspaceStepRunnerDelegate ensures that a given step runner run on switched workspace type workspaceStepRunnerDelegate struct { - terraformExecutor TerraformExec - defaultTfVersion *version.Version - delegate Runner + terraformExecutor TerraformExec + defaultTfDistribution terraform.Distribution + defaultTfVersion *version.Version + delegate Runner } -func NewWorkspaceStepRunnerDelegate(terraformExecutor TerraformExec, defaultTfVersion *version.Version, delegate Runner) Runner { +func NewWorkspaceStepRunnerDelegate(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version, delegate Runner) Runner { return &workspaceStepRunnerDelegate{ - terraformExecutor: terraformExecutor, - defaultTfVersion: defaultTfVersion, - delegate: delegate, + terraformExecutor: terraformExecutor, + defaultTfDistribution: defaultTfDistribution, + defaultTfVersion: defaultTfVersion, + delegate: delegate, } } func (r *workspaceStepRunnerDelegate) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { + tfDistribution := r.defaultTfDistribution tfVersion := r.defaultTfVersion + if ctx.TerraformDistribution != nil { + tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) + } if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } // We only need to switch workspaces in version 0.9.*. In older versions, // there is no such thing as a workspace so we don't need to do anything. - if err := r.switchWorkspace(ctx, path, tfVersion, envs); err != nil { + if err := r.switchWorkspace(ctx, path, tfDistribution, tfVersion, envs); err != nil { return "", err } @@ -40,7 +47,7 @@ func (r *workspaceStepRunnerDelegate) Run(ctx command.ProjectContext, extraArgs // switchWorkspace changes the terraform workspace if necessary and will create // it if it doesn't exist. It handles differences between versions. -func (r *workspaceStepRunnerDelegate) switchWorkspace(ctx command.ProjectContext, path string, tfVersion *version.Version, envs map[string]string) error { +func (r *workspaceStepRunnerDelegate) switchWorkspace(ctx command.ProjectContext, path string, tfDistribution terraform.Distribution, tfVersion *version.Version, envs map[string]string) error { // In versions less than 0.9 there is no support for workspaces. noWorkspaceSupport := MustConstraint("<0.9").Check(tfVersion) // If the user tried to set a specific workspace in the comment but their @@ -63,7 +70,7 @@ func (r *workspaceStepRunnerDelegate) switchWorkspace(ctx command.ProjectContext // already in the right workspace then no need to switch. This will save us // about ten seconds. This command is only available in > 0.10. if !runningZeroPointNine { - workspaceShowOutput, err := r.terraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "show"}, envs, tfVersion, ctx.Workspace) + workspaceShowOutput, err := r.terraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "show"}, envs, tfDistribution, tfVersion, ctx.Workspace) if err != nil { return err } @@ -78,11 +85,11 @@ func (r *workspaceStepRunnerDelegate) switchWorkspace(ctx command.ProjectContext // To do this we can either select and catch the error or use list and then // look for the workspace. Both commands take the same amount of time so // that's why we're running select here. - _, err := r.terraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "select", ctx.Workspace}, envs, tfVersion, ctx.Workspace) + _, err := r.terraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "select", ctx.Workspace}, envs, tfDistribution, tfVersion, ctx.Workspace) if err != nil { // If terraform workspace select fails we run terraform workspace // new to create a new workspace automatically. - out, err := r.terraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "new", ctx.Workspace}, envs, tfVersion, ctx.Workspace) + out, err := r.terraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "new", ctx.Workspace}, envs, tfDistribution, tfVersion, ctx.Workspace) if err != nil { return fmt.Errorf("%s: %s", err, out) } diff --git a/server/core/runtime/workspace_step_runner_delegate_test.go b/server/core/runtime/workspace_step_runner_delegate_test.go index 2ef3032d50..e705e93b00 100644 --- a/server/core/runtime/workspace_step_runner_delegate_test.go +++ b/server/core/runtime/workspace_step_runner_delegate_test.go @@ -6,7 +6,9 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" + tf "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" @@ -16,7 +18,9 @@ import ( func TestRun_NoWorkspaceIn08(t *testing.T) { // We don't want any workspace commands to be run in 0.8. RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.8") workspace := "default" logger := logging.NewNoopLogger(t) @@ -24,7 +28,7 @@ func TestRun_NoWorkspaceIn08(t *testing.T) { Log: logger, Workspace: workspace, } - s := NewWorkspaceStepRunnerDelegate(terraform, tfVersion, &NullRunner{}) + s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) _, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) @@ -36,6 +40,7 @@ func TestRun_NoWorkspaceIn08(t *testing.T) { "select", "workspace"}, map[string]string(nil), + tfDistribution, tfVersion, workspace) terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, @@ -44,6 +49,7 @@ func TestRun_NoWorkspaceIn08(t *testing.T) { "select", "workspace"}, map[string]string(nil), + tfDistribution, tfVersion, workspace) } @@ -52,11 +58,13 @@ func TestRun_ErrWorkspaceIn08(t *testing.T) { // If they attempt to use a workspace other than default in 0.8 // we should error. RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.8") logger := logging.NewNoopLogger(t) workspace := "notdefault" - s := NewWorkspaceStepRunnerDelegate(terraform, tfVersion, &NullRunner{}) + s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) _, err := s.Run(command.ProjectContext{ Log: logger, @@ -67,6 +75,8 @@ func TestRun_ErrWorkspaceIn08(t *testing.T) { func TestRun_SwitchesWorkspace(t *testing.T) { RegisterMockTestingT(t) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) cases := []struct { tfVersion string @@ -92,14 +102,14 @@ func TestRun_SwitchesWorkspace(t *testing.T) { for _, c := range cases { t.Run(c.tfVersion, func(t *testing.T) { - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() tfVersion, _ := version.NewVersion(c.tfVersion) logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Log: logger, Workspace: "workspace", } - s := NewWorkspaceStepRunnerDelegate(terraform, tfVersion, &NullRunner{}) + s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) _, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) @@ -111,12 +121,74 @@ func TestRun_SwitchesWorkspace(t *testing.T) { "select", "workspace"}, map[string]string(nil), + tfDistribution, tfVersion, "workspace") }) } } +func TestRun_SwitchesWorkspaceDistribution(t *testing.T) { + RegisterMockTestingT(t) + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) + + cases := []struct { + tfVersion string + tfDistribution string + expWorkspaceCmd string + }{ + { + "0.9.0", + "opentofu", + "env", + }, + { + "0.9.11", + "terraform", + "env", + }, + { + "0.10.0", + "terraform", + "workspace", + }, + { + "0.11.0", + "opentofu", + "workspace", + }, + } + + for _, c := range cases { + t.Run(c.tfVersion, func(t *testing.T) { + terraform := tfclientmocks.NewMockClient() + tfVersion, _ := version.NewVersion(c.tfVersion) + logger := logging.NewNoopLogger(t) + ctx := command.ProjectContext{ + Log: logger, + Workspace: "workspace", + TerraformDistribution: &c.tfDistribution, + } + s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) + + _, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) + Ok(t, err) + + // Verify that env select was called as well as plan. + terraform.VerifyWasCalledOnce().RunCommandWithVersion(Eq(ctx), + Eq("/path"), + Eq([]string{c.expWorkspaceCmd, + "select", + "workspace"}), + Eq(map[string]string(nil)), + NotEq(tfDistribution), + Eq(tfVersion), + Eq("workspace")) + }) + } +} + func TestRun_CreatesWorkspace(t *testing.T) { // Test that if `workspace select` fails, we call `workspace new`. RegisterMockTestingT(t) @@ -145,7 +217,9 @@ func TestRun_CreatesWorkspace(t *testing.T) { for _, c := range cases { t.Run(c.tfVersion, func(t *testing.T) { - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion(c.tfVersion) logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ @@ -163,20 +237,20 @@ func TestRun_CreatesWorkspace(t *testing.T) { Name: "repo", }, } - s := NewWorkspaceStepRunnerDelegate(terraform, tfVersion, &NullRunner{}) + s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) // Ensure that we actually try to switch workspaces by making the // output of `workspace show` to be a different name. - When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("diffworkspace\n", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfDistribution, tfVersion, "workspace")).ThenReturn("diffworkspace\n", nil) expWorkspaceArgs := []string{c.expWorkspaceCommand, "select", "workspace"} - When(terraform.RunCommandWithVersion(ctx, "/path", expWorkspaceArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("", errors.New("workspace does not exist")) + When(terraform.RunCommandWithVersion(ctx, "/path", expWorkspaceArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace")).ThenReturn("", errors.New("workspace does not exist")) _, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) // Verify that env select was called as well as plan. - terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expWorkspaceArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expWorkspaceArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") }) } } @@ -185,7 +259,9 @@ func TestRun_NoWorkspaceSwitchIfNotNecessary(t *testing.T) { // Tests that if workspace show says we're on the right workspace we don't // switch. RegisterMockTestingT(t) - terraform := mocks.NewMockClient() + terraform := tfclientmocks.NewMockClient() + mockDownloader := mocks.NewMockDownloader() + tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.10.0") logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ @@ -203,12 +279,12 @@ func TestRun_NoWorkspaceSwitchIfNotNecessary(t *testing.T) { Name: "repo", }, } - s := NewWorkspaceStepRunnerDelegate(terraform, tfVersion, &NullRunner{}) - When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("workspace\n", nil) + s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) + When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfDistribution, tfVersion, "workspace")).ThenReturn("workspace\n", nil) _, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) // Verify that workspace select was never called. - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, "/path", []string{"workspace", "select", "workspace"}, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, "/path", []string{"workspace", "select", "workspace"}, map[string]string(nil), tfDistribution, tfVersion, "workspace") } diff --git a/server/events/terraform/ansi/strip.go b/server/core/terraform/ansi/strip.go similarity index 100% rename from server/events/terraform/ansi/strip.go rename to server/core/terraform/ansi/strip.go diff --git a/server/events/terraform/ansi/strip_test.go b/server/core/terraform/ansi/strip_test.go similarity index 100% rename from server/events/terraform/ansi/strip_test.go rename to server/core/terraform/ansi/strip_test.go diff --git a/server/core/terraform/distribution.go b/server/core/terraform/distribution.go index 0fd781765d..dbeaf6a46b 100644 --- a/server/core/terraform/distribution.go +++ b/server/core/terraform/distribution.go @@ -18,6 +18,14 @@ type Distribution interface { ResolveConstraint(context.Context, string) (*version.Version, error) } +func NewDistribution(distribution string) Distribution { + tfDistribution := NewDistributionTerraform() + if distribution == "opentofu" { + tfDistribution = NewDistributionOpenTofu() + } + return tfDistribution +} + type DistributionOpenTofu struct { downloader Downloader } diff --git a/server/core/terraform/mocks/mock_terraform_client.go b/server/core/terraform/tfclient/mocks/mock_terraform_client.go similarity index 79% rename from server/core/terraform/mocks/mock_terraform_client.go rename to server/core/terraform/tfclient/mocks/mock_terraform_client.go index 279de1a751..9dca6ffd4b 100644 --- a/server/core/terraform/mocks/mock_terraform_client.go +++ b/server/core/terraform/tfclient/mocks/mock_terraform_client.go @@ -1,11 +1,12 @@ // Code generated by pegomock. DO NOT EDIT. -// Source: github.com/runatlantis/atlantis/server/core/terraform (interfaces: Client) +// Source: github.com/runatlantis/atlantis/server/core/terraform/tfclient (interfaces: Client) package mocks import ( go_version "github.com/hashicorp/go-version" pegomock "github.com/petergtz/pegomock/v4" + terraform "github.com/runatlantis/atlantis/server/core/terraform" command "github.com/runatlantis/atlantis/server/events/command" logging "github.com/runatlantis/atlantis/server/logging" "reflect" @@ -42,11 +43,11 @@ func (mock *MockClient) DetectVersion(log logging.SimpleLogging, projectDirector return _ret0 } -func (mock *MockClient) EnsureVersion(log logging.SimpleLogging, v *go_version.Version) error { +func (mock *MockClient) EnsureVersion(log logging.SimpleLogging, d terraform.Distribution, v *go_version.Version) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } - _params := []pegomock.Param{log, v} + _params := []pegomock.Param{log, d, v} _result := pegomock.GetGenericMockFrom(mock).Invoke("EnsureVersion", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { @@ -57,11 +58,11 @@ func (mock *MockClient) EnsureVersion(log logging.SimpleLogging, v *go_version.V return _ret0 } -func (mock *MockClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) (string, error) { +func (mock *MockClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, d terraform.Distribution, v *go_version.Version, workspace string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } - _params := []pegomock.Param{ctx, path, args, envs, v, workspace} + _params := []pegomock.Param{ctx, path, args, envs, d, v, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("RunCommandWithVersion", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error @@ -148,8 +149,8 @@ func (c *MockClient_DetectVersion_OngoingVerification) GetAllCapturedArguments() return } -func (verifier *VerifierMockClient) EnsureVersion(log logging.SimpleLogging, v *go_version.Version) *MockClient_EnsureVersion_OngoingVerification { - _params := []pegomock.Param{log, v} +func (verifier *VerifierMockClient) EnsureVersion(log logging.SimpleLogging, d terraform.Distribution, v *go_version.Version) *MockClient_EnsureVersion_OngoingVerification { + _params := []pegomock.Param{log, d, v} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "EnsureVersion", _params, verifier.timeout) return &MockClient_EnsureVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } @@ -159,12 +160,12 @@ type MockClient_EnsureVersion_OngoingVerification struct { methodInvocations []pegomock.MethodInvocation } -func (c *MockClient_EnsureVersion_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, *go_version.Version) { - log, v := c.GetAllCapturedArguments() - return log[len(log)-1], v[len(v)-1] +func (c *MockClient_EnsureVersion_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, terraform.Distribution, *go_version.Version) { + log, d, v := c.GetAllCapturedArguments() + return log[len(log)-1], d[len(d)-1], v[len(v)-1] } -func (c *MockClient_EnsureVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []*go_version.Version) { +func (c *MockClient_EnsureVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []terraform.Distribution, _param2 []*go_version.Version) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { @@ -174,17 +175,23 @@ func (c *MockClient_EnsureVersion_OngoingVerification) GetAllCapturedArguments() } } if len(_params) > 1 { - _param1 = make([]*go_version.Version, len(c.methodInvocations)) + _param1 = make([]terraform.Distribution, len(c.methodInvocations)) for u, param := range _params[1] { - _param1[u] = param.(*go_version.Version) + _param1[u] = param.(terraform.Distribution) + } + } + if len(_params) > 2 { + _param2 = make([]*go_version.Version, len(c.methodInvocations)) + for u, param := range _params[2] { + _param2[u] = param.(*go_version.Version) } } } return } -func (verifier *VerifierMockClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) *MockClient_RunCommandWithVersion_OngoingVerification { - _params := []pegomock.Param{ctx, path, args, envs, v, workspace} +func (verifier *VerifierMockClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, d terraform.Distribution, v *go_version.Version, workspace string) *MockClient_RunCommandWithVersion_OngoingVerification { + _params := []pegomock.Param{ctx, path, args, envs, d, v, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunCommandWithVersion", _params, verifier.timeout) return &MockClient_RunCommandWithVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } @@ -194,12 +201,12 @@ type MockClient_RunCommandWithVersion_OngoingVerification struct { methodInvocations []pegomock.MethodInvocation } -func (c *MockClient_RunCommandWithVersion_OngoingVerification) GetCapturedArguments() (command.ProjectContext, string, []string, map[string]string, *go_version.Version, string) { - ctx, path, args, envs, v, workspace := c.GetAllCapturedArguments() - return ctx[len(ctx)-1], path[len(path)-1], args[len(args)-1], envs[len(envs)-1], v[len(v)-1], workspace[len(workspace)-1] +func (c *MockClient_RunCommandWithVersion_OngoingVerification) GetCapturedArguments() (command.ProjectContext, string, []string, map[string]string, terraform.Distribution, *go_version.Version, string) { + ctx, path, args, envs, d, v, workspace := c.GetAllCapturedArguments() + return ctx[len(ctx)-1], path[len(path)-1], args[len(args)-1], envs[len(envs)-1], d[len(d)-1], v[len(v)-1], workspace[len(workspace)-1] } -func (c *MockClient_RunCommandWithVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 [][]string, _param3 []map[string]string, _param4 []*go_version.Version, _param5 []string) { +func (c *MockClient_RunCommandWithVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 [][]string, _param3 []map[string]string, _param4 []terraform.Distribution, _param5 []*go_version.Version, _param6 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { @@ -227,15 +234,21 @@ func (c *MockClient_RunCommandWithVersion_OngoingVerification) GetAllCapturedArg } } if len(_params) > 4 { - _param4 = make([]*go_version.Version, len(c.methodInvocations)) + _param4 = make([]terraform.Distribution, len(c.methodInvocations)) for u, param := range _params[4] { - _param4[u] = param.(*go_version.Version) + _param4[u] = param.(terraform.Distribution) } } if len(_params) > 5 { - _param5 = make([]string, len(c.methodInvocations)) + _param5 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[5] { - _param5[u] = param.(string) + _param5[u] = param.(*go_version.Version) + } + } + if len(_params) > 6 { + _param6 = make([]string, len(c.methodInvocations)) + for u, param := range _params[6] { + _param6[u] = param.(string) } } } diff --git a/server/core/terraform/terraform_client.go b/server/core/terraform/tfclient/terraform_client.go similarity index 91% rename from server/core/terraform/terraform_client.go rename to server/core/terraform/tfclient/terraform_client.go index d01525704b..5ef864db79 100644 --- a/server/core/terraform/terraform_client.go +++ b/server/core/terraform/tfclient/terraform_client.go @@ -13,8 +13,8 @@ // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // -// Package terraform handles the actual running of terraform commands. -package terraform +// Package tfclient handles the actual running of terraform commands. +package tfclient import ( "context" @@ -33,8 +33,9 @@ import ( "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/runtime/models" + "github.com/runatlantis/atlantis/server/core/terraform" + "github.com/runatlantis/atlantis/server/core/terraform/ansi" "github.com/runatlantis/atlantis/server/events/command" - "github.com/runatlantis/atlantis/server/events/terraform/ansi" "github.com/runatlantis/atlantis/server/jobs" "github.com/runatlantis/atlantis/server/logging" ) @@ -47,10 +48,10 @@ type Client interface { // RunCommandWithVersion executes terraform with args in path. If v is nil, // it will use the default Terraform version. workspace is the Terraform // workspace which should be set as an environment variable. - RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *version.Version, workspace string) (string, error) + RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, d terraform.Distribution, v *version.Version, workspace string) (string, error) // EnsureVersion makes sure that terraform version `v` is available to use - EnsureVersion(log logging.SimpleLogging, v *version.Version) error + EnsureVersion(log logging.SimpleLogging, d terraform.Distribution, v *version.Version) error // DetectVersion Extracts required_version from Terraform configuration in the specified project directory. Returns nil if unable to determine the version. DetectVersion(log logging.SimpleLogging, projectDirectory string) *version.Version @@ -58,7 +59,7 @@ type Client interface { type DefaultClient struct { // Distribution handles logic specific to the TF distribution being used by Atlantis - distribution Distribution + distribution terraform.Distribution // defaultVersion is the default version of terraform to use if another // version isn't specified. @@ -102,7 +103,7 @@ var versionRegex = regexp.MustCompile("(?:Terraform|OpenTofu) v(.*?)(\\s.*)?\n") // NewClientWithDefaultVersion creates a new terraform client and pre-fetches the default version func NewClientWithDefaultVersion( log logging.SimpleLogging, - distribution Distribution, + distribution terraform.Distribution, binDir string, cacheDir string, tfeToken string, @@ -189,7 +190,7 @@ func NewClientWithDefaultVersion( func NewTestClient( log logging.SimpleLogging, - distribution Distribution, + distribution terraform.Distribution, binDir string, cacheDir string, tfeToken string, @@ -227,7 +228,7 @@ func NewTestClient( // Will asynchronously download the required version if it doesn't exist already. func NewClient( log logging.SimpleLogging, - distribution Distribution, + distribution terraform.Distribution, binDir string, cacheDir string, tfeToken string, @@ -256,6 +257,10 @@ func NewClient( ) } +func (c *DefaultClient) DefaultDistribution() terraform.Distribution { + return c.distribution +} + // Version returns the default version of Terraform we use if no other version // is defined. func (c *DefaultClient) DefaultVersion() *version.Version { @@ -326,14 +331,14 @@ func (c *DefaultClient) DetectVersion(log logging.SimpleLogging, projectDirector } // See Client.EnsureVersion. -func (c *DefaultClient) EnsureVersion(log logging.SimpleLogging, v *version.Version) error { +func (c *DefaultClient) EnsureVersion(log logging.SimpleLogging, d terraform.Distribution, v *version.Version) error { if v == nil { v = c.defaultVersion } var err error c.versionsLock.Lock() - _, err = ensureVersion(log, c.distribution, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed) + _, err = ensureVersion(log, d, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed) c.versionsLock.Unlock() if err != nil { return err @@ -343,9 +348,9 @@ func (c *DefaultClient) EnsureVersion(log logging.SimpleLogging, v *version.Vers } // See Client.RunCommandWithVersion. -func (c *DefaultClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, customEnvVars map[string]string, v *version.Version, workspace string) (string, error) { +func (c *DefaultClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, customEnvVars map[string]string, d terraform.Distribution, v *version.Version, workspace string) (string, error) { if isAsyncEligibleCommand(args[0]) { - _, outCh := c.RunCommandAsync(ctx, path, args, customEnvVars, v, workspace) + _, outCh := c.RunCommandAsync(ctx, path, args, customEnvVars, d, v, workspace) var lines []string var err error @@ -362,7 +367,7 @@ func (c *DefaultClient) RunCommandWithVersion(ctx command.ProjectContext, path s output = ansi.Strip(output) return fmt.Sprintf("%s\n", output), err } - tfCmd, cmd, err := c.prepExecCmd(ctx.Log, v, workspace, path, args) + tfCmd, cmd, err := c.prepExecCmd(ctx.Log, d, v, workspace, path, args) if err != nil { return "", err } @@ -388,8 +393,8 @@ func (c *DefaultClient) RunCommandWithVersion(ctx command.ProjectContext, path s // prepExecCmd builds a ready to execute command based on the version of terraform // v, and args. It returns a printable representation of the command that will // be run and the actual command. -func (c *DefaultClient) prepExecCmd(log logging.SimpleLogging, v *version.Version, workspace string, path string, args []string) (string, *exec.Cmd, error) { - tfCmd, envVars, err := c.prepCmd(log, v, workspace, path, args) +func (c *DefaultClient) prepExecCmd(log logging.SimpleLogging, d terraform.Distribution, v *version.Version, workspace string, path string, args []string) (string, *exec.Cmd, error) { + tfCmd, envVars, err := c.prepCmd(log, d, v, workspace, path, args) if err != nil { return "", nil, err } @@ -401,7 +406,8 @@ func (c *DefaultClient) prepExecCmd(log logging.SimpleLogging, v *version.Versio // prepCmd prepares a shell command (to be interpreted with `sh -c `) and set of environment // variables for running terraform. -func (c *DefaultClient) prepCmd(log logging.SimpleLogging, v *version.Version, workspace string, path string, args []string) (string, []string, error) { +func (c *DefaultClient) prepCmd(log logging.SimpleLogging, d terraform.Distribution, v *version.Version, workspace string, path string, args []string) (string, []string, error) { + if v == nil { v = c.defaultVersion } @@ -413,7 +419,7 @@ func (c *DefaultClient) prepCmd(log logging.SimpleLogging, v *version.Version, w } else { var err error c.versionsLock.Lock() - binPath, err = ensureVersion(log, c.distribution, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed) + binPath, err = ensureVersion(log, d, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed) c.versionsLock.Unlock() if err != nil { return "", nil, err @@ -446,8 +452,8 @@ func (c *DefaultClient) prepCmd(log logging.SimpleLogging, v *version.Version, w // Callers can use the input channel to pass stdin input to the command. // If any error is passed on the out channel, there will be no // further output (so callers are free to exit). -func (c *DefaultClient) RunCommandAsync(ctx command.ProjectContext, path string, args []string, customEnvVars map[string]string, v *version.Version, workspace string) (chan<- string, <-chan models.Line) { - cmd, envVars, err := c.prepCmd(ctx.Log, v, workspace, path, args) +func (c *DefaultClient) RunCommandAsync(ctx command.ProjectContext, path string, args []string, customEnvVars map[string]string, d terraform.Distribution, v *version.Version, workspace string) (chan<- string, <-chan models.Line) { + cmd, envVars, err := c.prepCmd(ctx.Log, d, v, workspace, path, args) if err != nil { // The signature of `RunCommandAsync` doesn't provide for returning an immediate error, only one // once reading the output. Since we won't be spawning a process, simulate that by sending the @@ -486,7 +492,7 @@ func MustConstraint(v string) version.Constraints { // It will download this version if we don't have it. func ensureVersion( log logging.SimpleLogging, - dist Distribution, + dist terraform.Distribution, versions map[string]string, v *version.Version, binDir string, diff --git a/server/core/terraform/terraform_client_internal_test.go b/server/core/terraform/tfclient/terraform_client_internal_test.go similarity index 88% rename from server/core/terraform/terraform_client_internal_test.go rename to server/core/terraform/tfclient/terraform_client_internal_test.go index f92a3fd2d2..9cde70e399 100644 --- a/server/core/terraform/terraform_client_internal_test.go +++ b/server/core/terraform/tfclient/terraform_client_internal_test.go @@ -1,4 +1,4 @@ -package terraform +package tfclient import ( "fmt" @@ -10,6 +10,8 @@ import ( version "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" runtimemodels "github.com/runatlantis/atlantis/server/core/runtime/models" + "github.com/runatlantis/atlantis/server/core/terraform" + terraform_mocks "github.com/runatlantis/atlantis/server/core/terraform/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" @@ -120,7 +122,9 @@ func TestDefaultClient_RunCommandWithVersion_EnvVars(t *testing.T) { "DIR=$DIR", } customEnvVars := map[string]string{} - out, err := client.RunCommandWithVersion(ctx, tmp, args, customEnvVars, nil, "workspace") + mockDownloader := terraform_mocks.NewMockDownloader() + distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) + out, err := client.RunCommandWithVersion(ctx, tmp, args, customEnvVars, distribution, nil, "workspace") Ok(t, err) exp := fmt.Sprintf("TF_IN_AUTOMATION=true TF_PLUGIN_CACHE_DIR=%s WORKSPACE=workspace ATLANTIS_TERRAFORM_VERSION=0.11.11 DIR=%s\n", tmp, tmp) Equals(t, exp, out) @@ -163,7 +167,9 @@ func TestDefaultClient_RunCommandWithVersion_Error(t *testing.T) { "exit", "1", } - out, err := client.RunCommandWithVersion(ctx, tmp, args, map[string]string{}, nil, "workspace") + mockDownloader := terraform_mocks.NewMockDownloader() + distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) + out, err := client.RunCommandWithVersion(ctx, tmp, args, map[string]string{}, distribution, nil, "workspace") ErrEquals(t, fmt.Sprintf(`running 'echo dying && exit 1' in '%s': exit status 1`, tmp), err) // Test that we still get our output. Equals(t, "dying\n", out) @@ -209,7 +215,9 @@ func TestDefaultClient_RunCommandAsync_Success(t *testing.T) { "ATLANTIS_TERRAFORM_VERSION=$ATLANTIS_TERRAFORM_VERSION", "DIR=$DIR", } - _, outCh := client.RunCommandAsync(ctx, tmp, args, map[string]string{}, nil, "workspace") + mockDownloader := terraform_mocks.NewMockDownloader() + distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) + _, outCh := client.RunCommandAsync(ctx, tmp, args, map[string]string{}, distribution, nil, "workspace") out, err := waitCh(outCh) Ok(t, err) @@ -261,7 +269,9 @@ func TestDefaultClient_RunCommandAsync_BigOutput(t *testing.T) { _, err = f.WriteString(s) Ok(t, err) } - _, outCh := client.RunCommandAsync(ctx, tmp, []string{filename}, map[string]string{}, nil, "workspace") + mockDownloader := terraform_mocks.NewMockDownloader() + distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) + _, outCh := client.RunCommandAsync(ctx, tmp, []string{filename}, map[string]string{}, distribution, nil, "workspace") out, err := waitCh(outCh) Ok(t, err) @@ -301,7 +311,9 @@ func TestDefaultClient_RunCommandAsync_StderrOutput(t *testing.T) { overrideTF: "echo", projectCmdOutputHandler: projectCmdOutputHandler, } - _, outCh := client.RunCommandAsync(ctx, tmp, []string{"stderr", ">&2"}, map[string]string{}, nil, "workspace") + mockDownloader := terraform_mocks.NewMockDownloader() + distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) + _, outCh := client.RunCommandAsync(ctx, tmp, []string{"stderr", ">&2"}, map[string]string{}, distribution, nil, "workspace") out, err := waitCh(outCh) Ok(t, err) @@ -341,7 +353,9 @@ func TestDefaultClient_RunCommandAsync_ExitOne(t *testing.T) { overrideTF: "echo", projectCmdOutputHandler: projectCmdOutputHandler, } - _, outCh := client.RunCommandAsync(ctx, tmp, []string{"dying", "&&", "exit", "1"}, map[string]string{}, nil, "workspace") + mockDownloader := terraform_mocks.NewMockDownloader() + distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) + _, outCh := client.RunCommandAsync(ctx, tmp, []string{"dying", "&&", "exit", "1"}, map[string]string{}, distribution, nil, "workspace") out, err := waitCh(outCh) ErrEquals(t, fmt.Sprintf(`running 'sh -c' 'echo dying && exit 1' in '%s': exit status 1`, tmp), err) @@ -383,7 +397,9 @@ func TestDefaultClient_RunCommandAsync_Input(t *testing.T) { projectCmdOutputHandler: projectCmdOutputHandler, } - inCh, outCh := client.RunCommandAsync(ctx, tmp, []string{"a", "&&", "echo", "$a"}, map[string]string{}, nil, "workspace") + mockDownloader := terraform_mocks.NewMockDownloader() + distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) + inCh, outCh := client.RunCommandAsync(ctx, tmp, []string{"a", "&&", "echo", "$a"}, map[string]string{}, distribution, nil, "workspace") inCh <- "echo me\n" out, err := waitCh(outCh) diff --git a/server/core/terraform/terraform_client_test.go b/server/core/terraform/tfclient/terraform_client_test.go similarity index 86% rename from server/core/terraform/terraform_client_test.go rename to server/core/terraform/tfclient/terraform_client_test.go index 1c2c654495..50afd698a7 100644 --- a/server/core/terraform/terraform_client_test.go +++ b/server/core/terraform/tfclient/terraform_client_test.go @@ -11,7 +11,7 @@ // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. -package terraform_test +package tfclient_test import ( "context" @@ -28,6 +28,7 @@ import ( "github.com/runatlantis/atlantis/cmd" "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + "github.com/runatlantis/atlantis/server/core/terraform/tfclient" "github.com/runatlantis/atlantis/server/events/command" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" "github.com/runatlantis/atlantis/server/logging" @@ -42,12 +43,12 @@ func TestMustConstraint_PanicsOnBadConstraint(t *testing.T) { } }() - terraform.MustConstraint("invalid constraint") + tfclient.MustConstraint("invalid constraint") } func TestMustConstraint(t *testing.T) { t.Log("MustConstraint should return the constrain") - c := terraform.MustConstraint(">0.1") + c := tfclient.MustConstraint(">0.1") expectedConstraint, err := version.NewConstraint(">0.1") Ok(t, err) Equals(t, expectedConstraint.String(), c.String()) @@ -80,13 +81,13 @@ is 0.11.13. You can update by downloading from developer.hashicorp.com/terraform mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + c, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{"test": "123"}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{"test": "123"}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -117,13 +118,13 @@ is 0.11.13. You can update by downloading from developer.hashicorp.com/terraform mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + c, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -141,7 +142,7 @@ func TestNewClient_NoTF(t *testing.T) { mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - _, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + _, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) ErrEquals(t, "terraform not found in $PATH. Set --default-tf-version or download terraform from https://developer.hashicorp.com/terraform/downloads", err) } @@ -167,13 +168,13 @@ func TestNewClient_DefaultTFFlagInPath(t *testing.T) { mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, false, true, projectCmdOutputHandler) + c, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, false, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -198,13 +199,13 @@ func TestNewClient_DefaultTFFlagInBinDir(t *testing.T) { mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - c, err := terraform.NewClient(logging.NewNoopLogger(t), distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + c, err := tfclient.NewClient(logging.NewNoopLogger(t), distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -232,7 +233,7 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { return []ReturnValue{binPath, err} }) distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + c, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) @@ -243,7 +244,7 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { // Reset PATH so that it has sh. Ok(t, os.Setenv("PATH", orig)) - output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, distribution, nil, "") Ok(t, err) Equals(t, "\nTerraform v0.11.10\n\n", output) } @@ -255,7 +256,7 @@ func TestNewClient_BadVersion(t *testing.T) { projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - _, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "malformed", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + _, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "malformed", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) ErrEquals(t, "Malformed version: malformed", err) } @@ -283,11 +284,11 @@ func TestRunCommandWithVersion_DLsTF(t *testing.T) { return []ReturnValue{binPath, err} }) - c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + c, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, v, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, distribution, v, "") Assert(t, err == nil, "err: %s: %s", err, output) Equals(t, "\nTerraform v99.99.99\n\n", output) @@ -304,7 +305,7 @@ func TestEnsureVersion_downloaded(t *testing.T) { distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) downloadsAllowed := true - c, err := terraform.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, downloadsAllowed, true, projectCmdOutputHandler) + c, err := tfclient.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, downloadsAllowed, true, projectCmdOutputHandler) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) @@ -318,7 +319,7 @@ func TestEnsureVersion_downloaded(t *testing.T) { return []ReturnValue{binPath, err} }) - err = c.EnsureVersion(logger, v) + err = c.EnsureVersion(logger, distribution, v) Ok(t, err) @@ -337,7 +338,7 @@ func TestEnsureVersion_downloaded_customURL(t *testing.T) { downloadsAllowed := true customURL := "http://releases.example.com" - c, err := terraform.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, customURL, downloadsAllowed, true, projectCmdOutputHandler) + c, err := tfclient.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, customURL, downloadsAllowed, true, projectCmdOutputHandler) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) @@ -351,7 +352,7 @@ func TestEnsureVersion_downloaded_customURL(t *testing.T) { return []ReturnValue{binPath, err} }) - err = c.EnsureVersion(logger, v) + err = c.EnsureVersion(logger, distribution, v) Ok(t, err) @@ -369,7 +370,7 @@ func TestEnsureVersion_downloaded_downloadingDisabled(t *testing.T) { distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) downloadsAllowed := false - c, err := terraform.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, downloadsAllowed, true, projectCmdOutputHandler) + c, err := tfclient.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, downloadsAllowed, true, projectCmdOutputHandler) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) @@ -377,7 +378,7 @@ func TestEnsureVersion_downloaded_downloadingDisabled(t *testing.T) { v, err := version.NewVersion("99.99.99") Ok(t, err) - err = c.EnsureVersion(logger, v) + err = c.EnsureVersion(logger, distribution, v) ErrContains(t, "could not find terraform version", err) ErrContains(t, "downloads are disabled", err) mockDownloader.VerifyWasCalled(Never()) @@ -501,7 +502,7 @@ terraform { mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - c, err := terraform.NewTestClient( + c, err := tfclient.NewTestClient( logger, distribution, binDir, @@ -548,7 +549,7 @@ func TestExtractExactRegex(t *testing.T) { mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) - c, err := terraform.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) + c, err := tfclient.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) Ok(t, err) tests := []struct { diff --git a/server/events/apply_command_runner.go b/server/events/apply_command_runner.go index 6c69032910..c68110e518 100644 --- a/server/events/apply_command_runner.go +++ b/server/events/apply_command_runner.go @@ -94,10 +94,6 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { return } - if err = a.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.PendingCommitStatus, cmd.CommandName()); err != nil { - ctx.Log.Warn("unable to update commit status: %s", err) - } - // Get the mergeable status before we set any build statuses of our own. // We do this here because when we set a "Pending" status, if users have // required the Atlantis status checks to pass, then we've now changed diff --git a/server/events/apply_command_runner_test.go b/server/events/apply_command_runner_test.go index 6f713710f6..6ef5873c90 100644 --- a/server/events/apply_command_runner_test.go +++ b/server/events/apply_command_runner_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/locking" @@ -54,7 +54,7 @@ func TestApplyCommandRunner_IsLocked(t *testing.T) { scopeNull, _, _ := metrics.NewLoggingScope(logger, "atlantis") pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(logger, testdata.GithubRepo, testdata.Pull.Num)).ThenReturn(pull, nil) @@ -475,7 +475,7 @@ func TestApplyCommandRunner_ExecutionOrder(t *testing.T) { scopeNull, _, _ := metrics.NewLoggingScope(logger, "atlantis") pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} diff --git a/server/events/command/project_context.go b/server/events/command/project_context.go index 8fff2831d6..670aaa6c01 100644 --- a/server/events/command/project_context.go +++ b/server/events/command/project_context.go @@ -93,6 +93,10 @@ type ProjectContext struct { // Steps are the sequence of commands we need to run for this project and this // stage. Steps []valid.Step + // TerraformDistribution is the distribution of terraform we should use when + // executing commands for this project. This can be set to nil in which case + // we will use the default Atlantis terraform distribution. + TerraformDistribution *string // TerraformVersion is the version of terraform we should use when executing // commands for this project. This can be set to nil in which case we will // use the default Atlantis terraform version. diff --git a/server/events/command/scope_tags.go b/server/events/command/scope_tags.go index 2f51d86c83..8416927eab 100644 --- a/server/events/command/scope_tags.go +++ b/server/events/command/scope_tags.go @@ -7,12 +7,13 @@ import ( ) type ProjectScopeTags struct { - BaseRepo string - PrNumber string - Project string - ProjectPath string - TerraformVersion string - Workspace string + BaseRepo string + PrNumber string + Project string + ProjectPath string + TerraformDistribution string + TerraformVersion string + Workspace string } func (s ProjectScopeTags) Loadtags() map[string]string { diff --git a/server/events/command/team_allowlist_checker.go b/server/events/command/team_allowlist_checker.go index 5c58873650..f71684a6d7 100644 --- a/server/events/command/team_allowlist_checker.go +++ b/server/events/command/team_allowlist_checker.go @@ -21,6 +21,9 @@ type TeamAllowlistChecker interface { // IsCommandAllowedForAnyTeam determines if any of the specified teams can perform the specified action IsCommandAllowedForAnyTeam(ctx models.TeamAllowlistCheckerContext, teams []string, command string) bool + + // AllTeams returns all teams configured in the allowlist + AllTeams() []string } // DefaultTeamAllowlistChecker implements checking the teams and the operations that the members @@ -84,3 +87,14 @@ func (checker *DefaultTeamAllowlistChecker) IsCommandAllowedForAnyTeam(ctx model } return false } + +// AllTeams returns all teams configured in the allowlist +func (checker *DefaultTeamAllowlistChecker) AllTeams() []string { + var teamNames []string + for _, rule := range checker.rules { + for key := range rule { + teamNames = append(teamNames, key) + } + } + return teamNames +} diff --git a/server/events/command_runner.go b/server/events/command_runner.go index fdd4b39153..30a82105a9 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -17,7 +17,7 @@ import ( "fmt" "strconv" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/config/valid" @@ -30,7 +30,7 @@ import ( "github.com/runatlantis/atlantis/server/recovery" "github.com/runatlantis/atlantis/server/utils" tally "github.com/uber-go/tally/v4" - gitlab "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) const ( @@ -157,7 +157,7 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo // Check if the user who triggered the autoplan has permissions to run 'plan'. if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() { - err := c.fetchUserTeams(baseRepo, &user) + err := c.fetchUserTeams(log, baseRepo, &user) if err != nil { log.Err("Unable to fetch user teams: %s", err) return @@ -202,6 +202,12 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo cmd := &CommentCommand{ Name: command.Autoplan, } + + // Update the combined plan commit status to pending + if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { + ctx.Log.Warn("unable to update plan commit status: %s", err) + } + err = c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) if err != nil { @@ -300,7 +306,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead // Check if the user who commented has the permissions to execute the 'plan' or 'apply' commands if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() { - err := c.fetchUserTeams(baseRepo, &user) + err := c.fetchUserTeams(log, baseRepo, &user) if err != nil { c.Logger.Err("Unable to fetch user teams: %s", err) return @@ -354,6 +360,18 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead return } + // Update the combined plan or apply commit status to pending + switch cmd.Name { + case command.Plan: + if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { + ctx.Log.Warn("unable to update plan commit status: %s", err) + } + case command.Apply: + if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Apply); err != nil { + ctx.Log.Warn("unable to update apply commit status: %s", err) + } + } + err = c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) if err != nil { @@ -491,8 +509,8 @@ func (c *DefaultCommandRunner) ensureValidRepoMetadata( return } -func (c *DefaultCommandRunner) fetchUserTeams(repo models.Repo, user *models.User) error { - teams, err := c.VCSClient.GetTeamNamesForUser(repo, *user) +func (c *DefaultCommandRunner) fetchUserTeams(logger logging.SimpleLogging, repo models.Repo, user *models.User) error { + teams, err := c.VCSClient.GetTeamNamesForUser(logger, repo, *user) if err != nil { return err } diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 8764c08bea..4e32994824 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -27,7 +27,7 @@ import ( "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" . "github.com/petergtz/pegomock/v4" lockingmocks "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events" @@ -126,7 +126,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock pullUpdater = &events.PullUpdater{ HidePrevPlanComments: false, VCSClient: vcsClient, - MarkdownRenderer: events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false), + MarkdownRenderer: events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false, false), } autoMerger = &events.AutoMerger{ @@ -253,6 +253,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, PullStatusFetcher: testConfig.backend, + CommitStatusUpdater: commitUpdater, } return vcsClient @@ -313,7 +314,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) { When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) - vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User) + vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(ch.Logger, testdata.GithubRepo, testdata.User) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("Ran Plan for 0 projects:"), Eq("plan")) }) @@ -331,7 +332,7 @@ func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) { When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) - vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(testdata.GithubRepo, testdata.User) + vcsClient.VerifyWasCalled(Never()).GetTeamNamesForUser(ch.Logger, testdata.GithubRepo, testdata.User) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("Ran Plan for 0 projects:"), Eq("plan")) }) @@ -440,15 +441,8 @@ func TestRunCommentCommandApply_NoProjects_SilenceEnabled(t *testing.T) { ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Apply}) vcsClient.VerifyWasCalled(Never()).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) - commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( - Any[logging.SimpleLogging](), - Any[models.Repo](), - Any[models.PullRequest](), - Eq[models.CommitStatus](models.SuccessCommitStatus), - Eq[command.Name](command.Apply), - Eq(0), - Eq(0), - ) + commitUpdater.VerifyWasCalledOnce().UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq(models.PendingCommitStatus), Eq(command.Apply)) } func TestRunCommentCommandApprovePolicy_NoProjects_SilenceEnabled(t *testing.T) { @@ -463,15 +457,6 @@ func TestRunCommentCommandApprovePolicy_NoProjects_SilenceEnabled(t *testing.T) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.ApprovePolicies}) vcsClient.VerifyWasCalled(Never()).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) - commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( - Any[logging.SimpleLogging](), - Any[models.Repo](), - Any[models.PullRequest](), - Eq[models.CommitStatus](models.SuccessCommitStatus), - Eq[command.Name](command.PolicyCheck), - Eq(0), - Eq(0), - ) } func TestRunCommentCommandUnlock_NoProjects_SilenceEnabled(t *testing.T) { @@ -485,6 +470,8 @@ func TestRunCommentCommandUnlock_NoProjects_SilenceEnabled(t *testing.T) { ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) vcsClient.VerifyWasCalled(Never()).CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq(models.PendingCommitStatus), Any[command.Name]()) } func TestRunCommentCommandImport_NoProjects_SilenceEnabled(t *testing.T) { @@ -506,7 +493,7 @@ func TestRunCommentCommand_DisableApplyAllDisabled(t *testing.T) { vcsClient := setup(t) applyCommandRunner.DisableApplyAll = true pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) @@ -535,7 +522,7 @@ func TestRunCommentCommand_DisableAutoplan(t *testing.T) { CommandName: command.Plan, }, }, nil) - + When(commitUpdater.UpdateCombinedCount(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[int](), Any[int]())).ThenReturn(nil) ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, modelPull, testdata.User) projectCommandBuilder.VerifyWasCalled(Never()).BuildAutoplanCommands(Any[*command.Context]()) } @@ -594,7 +581,7 @@ func TestRunCommentCommand_ClosedPull(t *testing.T) { " comment saying that this is not allowed") vcsClient := setup(t) pull := &github.PullRequest{ - State: github.String("closed"), + State: github.Ptr("closed"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.ClosedPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) @@ -647,12 +634,12 @@ func TestRunUnlockCommand_VCSComment(t *testing.T) { }{ { name: "PR open", - prState: github.String("open"), + prState: github.Ptr("open"), }, { name: "PR closed", - prState: github.String("closed"), + prState: github.Ptr("closed"), }, } @@ -689,7 +676,7 @@ func TestRunUnlockCommandFail_VCSComment(t *testing.T) { vcsClient := setup(t) pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), @@ -713,7 +700,7 @@ func TestRunUnlockCommandFail_DisableUnlockLabel(t *testing.T) { vcsClient := setup(t) pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), @@ -737,7 +724,7 @@ func TestRunUnlockCommandFail_GetLabelsFail(t *testing.T) { vcsClient := setup(t) pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), @@ -763,7 +750,7 @@ func TestRunUnlockCommandDoesntRetrieveLabelsIfDisableUnlockLabelNotSet(t *testi vcsClient := setup(t) pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), @@ -831,6 +818,10 @@ func TestRunAutoplanCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_Fal ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) pendingPlanFinder.VerifyWasCalledOnce().DeletePlans(tmp) lockingLocker.VerifyWasCalledOnce().UnlockByPull(testdata.Pull.BaseRepo.FullName, testdata.Pull.Num) + commitUpdater.VerifyWasCalledOnce().UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), + Eq(models.PendingCommitStatus), Eq(command.Plan)) + commitUpdater.VerifyWasCalled(Never()).UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), + Eq(models.FailedCommitStatus), Any[command.Name]()) } func TestRunAutoplanCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_True(t *testing.T) { @@ -853,6 +844,8 @@ func TestRunAutoplanCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_Tru ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) pendingPlanFinder.VerifyWasCalled(Never()).DeletePlans(Any[string]()) lockingLocker.VerifyWasCalled(Never()).UnlockByPull(Any[string](), Any[int]()) + commitUpdater.VerifyWasCalledOnce().UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), + Eq(models.PendingCommitStatus), Eq(command.Plan)) } func TestRunCommentCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_False(t *testing.T) { @@ -865,7 +858,7 @@ func TestRunCommentCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_Fals When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectResult{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) - pull := &github.PullRequest{State: github.String("open")} + pull := &github.PullRequest{State: github.Ptr("open")} modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) @@ -907,7 +900,7 @@ func TestRunGenericPlanCommand_DeletePlans(t *testing.T) { When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectResult{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) - pull := &github.PullRequest{State: github.String("open")} + pull := &github.PullRequest{State: github.Ptr("open")} modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) @@ -1001,7 +994,7 @@ func TestRunGenericPlanCommand_DiscardApprovals(t *testing.T) { When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectResult{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) - pull := &github.PullRequest{State: github.String("open")} + pull := &github.PullRequest{State: github.Ptr("open")} modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) @@ -1025,7 +1018,7 @@ func TestFailedApprovalCreatesFailedStatusUpdate(t *testing.T) { defer func() { autoMerger.GlobalAutomerge = false }() pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{ @@ -1071,7 +1064,7 @@ func TestApprovedPoliciesUpdateFailedPolicyStatus(t *testing.T) { defer func() { autoMerger.GlobalAutomerge = false }() pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{ @@ -1127,7 +1120,7 @@ func TestApplyMergeablityWhenPolicyCheckFails(t *testing.T) { defer func() { autoMerger.GlobalAutomerge = false }() pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{ @@ -1174,7 +1167,7 @@ func TestApplyWithAutoMerge_VSCMerge(t *testing.T) { vcsClient := setup(t) pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState} When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) @@ -1217,7 +1210,7 @@ func TestRunApply_DiscardedProjects(t *testing.T) { Ok(t, err) Ok(t, boltDB.UpdateProjectStatus(pull, "default", ".", models.DiscardedPlanStatus)) ghPull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(ghPull, nil) When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(ghPull))).ThenReturn(pull, pull.BaseRepo, testdata.GithubRepo, nil) diff --git a/server/events/comment_parser.go b/server/events/comment_parser.go index 829c15ced9..36cf0651ec 100644 --- a/server/events/comment_parser.go +++ b/server/events/comment_parser.go @@ -145,6 +145,7 @@ type CommentParseResult struct { // - atlantis import ADDRESS ID func (e *CommentParser) Parse(rawComment string, vcsHost models.VCSHostType) CommentParseResult { comment := strings.TrimSpace(rawComment) + comment = strings.Trim(comment, "`") if multiLineRegex.MatchString(comment) { return CommentParseResult{Ignore: true} diff --git a/server/events/comment_parser_test.go b/server/events/comment_parser_test.go index 88ededcfff..bf48469302 100644 --- a/server/events/comment_parser_test.go +++ b/server/events/comment_parser_test.go @@ -144,6 +144,33 @@ func TestParse_HelpResponse(t *testing.T) { } } +func TestParse_TrimCommandString(t *testing.T) { + t.Log("commands should be trimmed of whitespace and backtick (helps with Gitlab copy/paste issues)") + allowCommandsCases := [][]command.Name{ + command.AllCommentCommands, + {}, // empty case + } + helpComments := []string{ + "`atlantis help`", + "` atlantis help `", + "`atlantis help` ", + " `atlantis help", + } + for _, allowCommandCase := range allowCommandsCases { + for _, c := range helpComments { + t.Run(fmt.Sprintf("%s with allow commands %v", c, allowCommandCase), func(t *testing.T) { + commentParser := events.CommentParser{ + GithubUser: "github-user", + ExecutableName: "atlantis", + AllowCommands: allowCommandCase, + } + r := commentParser.Parse(c, models.Github) + Equals(t, commentParser.HelpComment(), r.CommentResponse) + }) + } + } +} + func TestParse_UnusedArguments(t *testing.T) { t.Log("if there are unused flags we return an error") cases := []struct { diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 7ab18b07ca..c9cae1c828 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -24,7 +24,7 @@ import ( giteasdk "code.gitea.io/sdk/gitea" "github.com/go-playground/validator/v10" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" lru "github.com/hashicorp/golang-lru/v2" "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/pkg/errors" @@ -34,7 +34,7 @@ import ( "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" - "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) const gitlabPullOpened = "opened" diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index ef8f2de627..27515be71d 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -21,7 +21,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/mcdafydd/go-azuredevops/azuredevops" "github.com/mohae/deepcopy" "github.com/runatlantis/atlantis/server/events" @@ -30,7 +30,7 @@ import ( . "github.com/runatlantis/atlantis/server/events/vcs/testdata" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" - gitlab "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) var parser = events.EventParser{ @@ -68,12 +68,12 @@ func TestParseGithubIssueCommentEvent(t *testing.T) { comment := github.IssueCommentEvent{ Repo: &Repo, Issue: &github.Issue{ - Number: github.Int(1), - User: &github.User{Login: github.String("issue_user")}, - HTMLURL: github.String("https://github.com/runatlantis/atlantis/issues/1"), + Number: github.Ptr(1), + User: &github.User{Login: github.Ptr("issue_user")}, + HTMLURL: github.Ptr("https://github.com/runatlantis/atlantis/issues/1"), }, Comment: &github.IssueComment{ - User: &github.User{Login: github.String("comment_user")}, + User: &github.User{Login: github.Ptr("comment_user")}, }, } @@ -170,8 +170,8 @@ func TestParseGithubPullEventFromDraft(t *testing.T) { logger := logging.NewNoopLogger(t) // verify that close event treated as 'close' events by default closeEvent := deepcopy.Copy(PullEvent).(github.PullRequestEvent) - closeEvent.Action = github.String("closed") - closeEvent.PullRequest.Draft = github.Bool(true) + closeEvent.Action = github.Ptr("closed") + closeEvent.PullRequest.Draft = github.Ptr(true) _, evType, _, _, _, err := parser.ParseGithubPullEvent(logger, &closeEvent) Ok(t, err) @@ -179,7 +179,7 @@ func TestParseGithubPullEventFromDraft(t *testing.T) { // verify that draft PRs are treated as 'other' events by default testEvent := deepcopy.Copy(PullEvent).(github.PullRequestEvent) - testEvent.PullRequest.Draft = github.Bool(true) + testEvent.PullRequest.Draft = github.Ptr(true) _, evType, _, _, _, err = parser.ParseGithubPullEvent(logger, &testEvent) Ok(t, err) Equals(t, models.OtherPullEvent, evType) diff --git a/server/events/external_team_allowlist_checker.go b/server/events/external_team_allowlist_checker.go index 9f3fe419ef..6592b6b181 100644 --- a/server/events/external_team_allowlist_checker.go +++ b/server/events/external_team_allowlist_checker.go @@ -39,6 +39,10 @@ func (checker *ExternalTeamAllowlistChecker) IsCommandAllowedForAnyTeam(ctx mode return checker.checkOutputResults(out) } +func (checker *ExternalTeamAllowlistChecker) AllTeams() []string { + return []string{} +} + func (checker *ExternalTeamAllowlistChecker) buildCommandString(ctx models.TeamAllowlistCheckerContext, teams []string, command string) string { // Build command string // Format is "$external_cmd $external_args $command $repo $teams" diff --git a/server/events/markdown_renderer.go b/server/events/markdown_renderer.go index 4ce268c239..e685122b08 100644 --- a/server/events/markdown_renderer.go +++ b/server/events/markdown_renderer.go @@ -57,6 +57,7 @@ type MarkdownRenderer struct { markdownTemplates *template.Template executableName string hideUnchangedPlanComments bool + quietPolicyChecks bool } // commonData is data that all responses have. @@ -72,6 +73,7 @@ type commonData struct { EnableDiffMarkdownFormat bool ExecutableName string HideUnchangedPlanComments bool + QuietPolicyChecks bool VcsRequestType string } @@ -131,11 +133,12 @@ type policyCheckResultsData struct { } type projectResultTmplData struct { - Workspace string - RepoRelDir string - ProjectName string - Rendered string - NoChanges bool + Workspace string + RepoRelDir string + ProjectName string + Rendered string + NoChanges bool + IsSuccessful bool } // Initialize templates @@ -149,6 +152,7 @@ func NewMarkdownRenderer( markdownTemplateOverridesDir string, executableName string, hideUnchangedPlanComments bool, + quietPolicyChecks bool, ) *MarkdownRenderer { var templates *template.Template templates, _ = template.New("").Funcs(sprig.TxtFuncMap()).ParseFS(templatesFS, "templates/*.tmpl") @@ -166,6 +170,7 @@ func NewMarkdownRenderer( markdownTemplates: templates, executableName: executableName, hideUnchangedPlanComments: hideUnchangedPlanComments, + quietPolicyChecks: quietPolicyChecks, } } @@ -192,6 +197,7 @@ func (m *MarkdownRenderer) Render(ctx *command.Context, res command.Result, cmd EnableDiffMarkdownFormat: m.enableDiffMarkdownFormat, ExecutableName: m.executableName, HideUnchangedPlanComments: m.hideUnchangedPlanComments, + QuietPolicyChecks: m.quietPolicyChecks, VcsRequestType: vcsRequestType, } @@ -224,9 +230,10 @@ func (m *MarkdownRenderer) renderProjectResults(ctx *command.Context, results [] for _, result := range results { resultData := projectResultTmplData{ - Workspace: result.Workspace, - RepoRelDir: result.RepoRelDir, - ProjectName: result.ProjectName, + Workspace: result.Workspace, + RepoRelDir: result.RepoRelDir, + ProjectName: result.ProjectName, + IsSuccessful: result.IsSuccessful(), } if result.PlanSuccess != nil { result.PlanSuccess.TerraformOutput = strings.TrimSpace(result.PlanSuccess.TerraformOutput) diff --git a/server/events/markdown_renderer_test.go b/server/events/markdown_renderer_test.go index 39810dab13..2fb90c256b 100644 --- a/server/events/markdown_renderer_test.go +++ b/server/events/markdown_renderer_test.go @@ -60,7 +60,18 @@ func TestRenderErr(t *testing.T) { }, } - r := events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false) + r := events.NewMarkdownRenderer( + false, // gitlabSupportsCommonMark + false, // disableApplyAll + false, // disableApply + false, // disableMarkdownFolding + false, // disableRepoLocking + false, // enableDiffMarkdownFormat + "", // markdownTemplateOverridesDir + "atlantis", // executableName + false, // hideUnchangedPlanComments + false, // quietPolicyChecks + ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" logger.Info(logText) @@ -124,7 +135,18 @@ func TestRenderFailure(t *testing.T) { }, } - r := events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false) + r := events.NewMarkdownRenderer( + false, // gitlabSupportsCommonMark + false, // disableApplyAll + false, // disableApply + false, // disableMarkdownFolding + false, // disableRepoLocking + false, // enableDiffMarkdownFormat + "", // markdownTemplateOverridesDir + "atlantis", // executableName + false, // hideUnchangedPlanComments + false, // quietPolicyChecks + ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" logger.Info(logText) @@ -163,7 +185,18 @@ func TestRenderFailure(t *testing.T) { } func TestRenderErrAndFailure(t *testing.T) { - r := events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false) + r := events.NewMarkdownRenderer( + false, // gitlabSupportsCommonMark + false, // disableApplyAll + false, // disableApply + false, // disableMarkdownFolding + false, // disableRepoLocking + false, // enableDiffMarkdownFormat + "", // markdownTemplateOverridesDir + "atlantis", // executableName + false, // hideUnchangedPlanComments + false, // quietPolicyChecks + ) logger := logging.NewNoopLogger(t).WithHistory() ctx := &command.Context{ Log: logger, @@ -1159,7 +1192,392 @@ $$$ }, } - r := events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false) + r := events.NewMarkdownRenderer( + false, // gitlabSupportsCommonMark + false, // disableApplyAll + false, // disableApply + false, // disableMarkdownFolding + false, // disableRepoLocking + false, // enableDiffMarkdownFormat + "", // markdownTemplateOverridesDir + "atlantis", // executableName + false, // hideUnchangedPlanComments + false, // quietPolicyChecks + ) + logger := logging.NewNoopLogger(t).WithHistory() + logText := "log" + logger.Info(logText) + ctx := &command.Context{ + Log: logger, + Pull: models.PullRequest{ + BaseRepo: models.Repo{ + VCSHost: models.VCSHost{ + Type: models.Github, + }, + }, + }, + } + for _, c := range cases { + t.Run(c.Description, func(t *testing.T) { + res := command.Result{ + ProjectResults: c.ProjectResults, + } + for _, verbose := range []bool{true, false} { + t.Run(c.Description, func(t *testing.T) { + cmd := &events.CommentCommand{ + Name: c.Command, + SubName: c.SubCommand, + Verbose: verbose, + } + s := r.Render(ctx, res, cmd) + if !verbose { + Equals(t, normalize(c.Expected), normalize(s)) + } else { + log := fmt.Sprintf("[INFO] %s", logText) + Equals(t, normalize(c.Expected+ + fmt.Sprintf("
Log\n

\n\n```\n%s\n```\n

", log)), normalize(s)) + } + }) + } + }) + } +} + +func TestRenderProjectResultsWithQuietPolicyChecks(t *testing.T) { + cases := []struct { + Description string + Command command.Name + SubCommand string + ProjectResults []command.ProjectResult + VCSHost models.VCSHostType + Expected string + }{ + { + "single successful policy check with multiple policy sets and project name", + command.PolicyCheck, + "", + []command.ProjectResult{ + { + PolicyCheckResults: &models.PolicyCheckResults{ + PolicySetResults: []models.PolicySetResult{ + { + PolicySetName: "policy1", + PolicyOutput: `FAIL - - main - WARNING: Null Resource creation is prohibited. + +2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions`, + Passed: false, + ReqApprovals: 1, + }, + { + PolicySetName: "policy2", + PolicyOutput: "2 tests, 2 passed, 0 warnings, 0 failure, 0 exceptions", + Passed: true, + ReqApprovals: 1, + }, + }, + LockURL: "lock-url", + RePlanCmd: "atlantis plan -d path -w workspace", + ApplyCmd: "atlantis apply -d path -w workspace", + }, + Workspace: "workspace", + RepoRelDir: "path", + ProjectName: "projectname", + }, + }, + models.Github, + ` +Ran Policy Check for project: $projectname$ dir: $path$ workspace: $workspace$ + +#### Policy Set: $policy1$ +$$$diff +FAIL - - main - WARNING: Null Resource creation is prohibited. + +2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions +$$$ + +#### Policy Set: $policy2$ +$$$diff +2 tests, 2 passed, 0 warnings, 0 failure, 0 exceptions +$$$ + + +#### Policy Approval Status: +$$$ +policy set: policy1: requires: 1 approval(s), have: 0. +policy set: policy2: passed. +$$$ +* :heavy_check_mark: To **approve** this project, comment: + $$$shell + + $$$ +* :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) +* :repeat: To re-run policies **plan** this project again by commenting: + $$$shell + atlantis plan -d path -w workspace + $$$ + +--- +* :fast_forward: To **apply** all unapplied plans from this Pull Request, comment: + $$$shell + atlantis apply + $$$ +* :put_litter_in_its_place: To **delete** all plans and locks from this Pull Request, comment: + $$$shell + atlantis unlock + $$$ +`, + }, + { + "single successful policy check with project name", + command.PolicyCheck, + "", + []command.ProjectResult{ + { + PolicyCheckResults: &models.PolicyCheckResults{ + PolicySetResults: []models.PolicySetResult{ + { + PolicySetName: "policy1", + // strings.Repeat require to get wrapped result + PolicyOutput: strings.Repeat("line\n", 13) + `FAIL - - main - WARNING: Null Resource creation is prohibited. + +2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions`, + Passed: false, + ReqApprovals: 1, + }, + }, + LockURL: "lock-url", + RePlanCmd: "atlantis plan -d path -w workspace", + ApplyCmd: "atlantis apply -d path -w workspace", + }, + Workspace: "workspace", + RepoRelDir: "path", + ProjectName: "projectname", + }, + }, + models.Github, + ` +Ran Policy Check for project: $projectname$ dir: $path$ workspace: $workspace$ + +
Show Output + +#### Policy Set: $policy1$ +$$$diff +line +line +line +line +line +line +line +line +line +line +line +line +line +FAIL - - main - WARNING: Null Resource creation is prohibited. + +2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions +$$$ + + +
+ +#### Policy Approval Status: +$$$ +policy set: policy1: requires: 1 approval(s), have: 0. +$$$ +* :heavy_check_mark: To **approve** this project, comment: + $$$shell + + $$$ +* :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) +* :repeat: To re-run policies **plan** this project again by commenting: + $$$shell + atlantis plan -d path -w workspace + $$$ +$$$ +policy set: policy1: 2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions +$$$ + +--- +* :fast_forward: To **apply** all unapplied plans from this Pull Request, comment: + $$$shell + atlantis apply + $$$ +* :put_litter_in_its_place: To **delete** all plans and locks from this Pull Request, comment: + $$$shell + atlantis unlock + $$$ +`, + }, + { + "multiple successful policy checks", + command.PolicyCheck, + "", + []command.ProjectResult{ + { + Workspace: "workspace", + RepoRelDir: "path", + PolicyCheckResults: &models.PolicyCheckResults{ + PolicySetResults: []models.PolicySetResult{ + { + PolicySetName: "policy1", + PolicyOutput: "4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions", + Passed: true, + }, + }, + LockURL: "lock-url", + ApplyCmd: "atlantis apply -d path -w workspace", + RePlanCmd: "atlantis plan -d path -w workspace", + }, + }, + { + Workspace: "workspace", + RepoRelDir: "path2", + ProjectName: "projectname", + PolicyCheckResults: &models.PolicyCheckResults{ + PolicySetResults: []models.PolicySetResult{ + { + PolicySetName: "policy1", + PolicyOutput: "4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions", + Passed: true, + }, + }, LockURL: "lock-url2", + ApplyCmd: "atlantis apply -d path2 -w workspace", + RePlanCmd: "atlantis plan -d path2 -w workspace", + }, + }, + }, + models.Github, + ` +Ran Policy Check for 2 projects: + +1. dir: $path$ workspace: $workspace$ +1. project: $projectname$ dir: $path2$ workspace: $workspace$ +--- + +* :fast_forward: To **apply** all unapplied plans from this Pull Request, comment: + $$$shell + atlantis apply + $$$ +* :put_litter_in_its_place: To **delete** all plans and locks from this Pull Request, comment: + $$$shell + atlantis unlock + $$$ +`, + }, + { + "successful, failed, and errored policy check", + command.PolicyCheck, + "", + []command.ProjectResult{ + { + Workspace: "workspace", + RepoRelDir: "path", + PolicyCheckResults: &models.PolicyCheckResults{ + PolicySetResults: []models.PolicySetResult{ + { + PolicySetName: "policy1", + PolicyOutput: "4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions", + Passed: true, + }, + }, LockURL: "lock-url", + ApplyCmd: "atlantis apply -d path -w workspace", + RePlanCmd: "atlantis plan -d path -w workspace", + }, + }, + { + Workspace: "workspace", + RepoRelDir: "path2", + Failure: "failure", + PolicyCheckResults: &models.PolicyCheckResults{ + PolicySetResults: []models.PolicySetResult{ + { + PolicySetName: "policy1", + PolicyOutput: "4 tests, 2 passed, 0 warnings, 2 failures, 0 exceptions", + Passed: false, + ReqApprovals: 1, + }, + }, LockURL: "lock-url", + ApplyCmd: "atlantis apply -d path -w workspace", + RePlanCmd: "atlantis plan -d path -w workspace", + }, + }, + { + Workspace: "workspace", + RepoRelDir: "path3", + ProjectName: "projectname", + Error: errors.New("error"), + }, + }, + models.Github, + ` +Ran Policy Check for 3 projects: + +1. dir: $path$ workspace: $workspace$ +1. dir: $path2$ workspace: $workspace$ +1. project: $projectname$ dir: $path3$ workspace: $workspace$ +--- + +### 2. dir: $path2$ workspace: $workspace$ +**Policy Check Failed**: failure +#### Policy Set: $policy1$ +$$$diff +4 tests, 2 passed, 0 warnings, 2 failures, 0 exceptions +$$$ + + +#### Policy Approval Status: +$$$ +policy set: policy1: requires: 1 approval(s), have: 0. +$$$ +* :heavy_check_mark: To **approve** this project, comment: + $$$shell + + $$$ +* :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) +* :repeat: To re-run policies **plan** this project again by commenting: + $$$shell + atlantis plan -d path -w workspace + $$$ + +--- +### 3. project: $projectname$ dir: $path3$ workspace: $workspace$ +**Policy Check Error** +$$$ +error +$$$ + +--- +* :heavy_check_mark: To **approve** all unapplied plans from this Pull Request, comment: + $$$shell + atlantis approve_policies + $$$ +* :put_litter_in_its_place: To **delete** all plans and locks from this Pull Request, comment: + $$$shell + atlantis unlock + $$$ +* :repeat: To re-run policies **plan** this project again by commenting: + $$$shell + atlantis plan + $$$ +`, + }, + } + + r := events.NewMarkdownRenderer( + false, // gitlabSupportsCommonMark + false, // disableApplyAll + false, // disableApply + false, // disableMarkdownFolding + false, // disableRepoLocking + false, // enableDiffMarkdownFormat + "", // markdownTemplateOverridesDir + "atlantis", // executableName + false, // hideUnchangedPlanComments + true, // quietPolicyChecks + ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" logger.Info(logText) @@ -1356,9 +1774,10 @@ $$$ false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -1540,9 +1959,10 @@ $$$ false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -1598,9 +2018,10 @@ func TestRenderCustomPolicyCheckTemplate_DisableApplyAll(t *testing.T) { false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - tmpDir, // MarkdownTemplateOverridesDir + tmpDir, // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -1672,9 +2093,10 @@ func TestRenderProjectResults_DisableFolding(t *testing.T) { true, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -1781,9 +2203,10 @@ func TestRenderProjectResults_WrappedErr(t *testing.T) { false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -1926,9 +2349,10 @@ func TestRenderProjectResults_WrapSingleProject(t *testing.T) { false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -2076,9 +2500,10 @@ func TestRenderProjectResults_MultiProjectApplyWrapped(t *testing.T) { false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -2155,9 +2580,10 @@ func TestRenderProjectResults_MultiProjectPlanWrapped(t *testing.T) { false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -2381,9 +2807,10 @@ This plan was not saved because one or more projects failed and automerge requir false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -2937,9 +3364,10 @@ $$$ false, // disableMarkdownFolding true, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -3074,9 +3502,10 @@ $$$ false, // disableMarkdownFolding true, // disableRepoLocking false, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -3533,9 +3962,10 @@ func TestRenderProjectResultsWithEnableDiffMarkdownFormat(t *testing.T) { false, // disableMarkdownFolding false, // disableRepoLocking true, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" @@ -3588,9 +4018,10 @@ func BenchmarkRenderProjectResultsWithEnableDiffMarkdownFormat(b *testing.B) { false, // disableMarkdownFolding false, // disableRepoLocking true, // enableDiffMarkdownFormat - "", // MarkdownTemplateOverridesDir + "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments + false, // quietPolicyChecks ) logger := logging.NewNoopLogger(b).WithHistory() logText := "log" @@ -3793,7 +4224,18 @@ Ran Plan for 3 projects: }, } - r := events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", true) + r := events.NewMarkdownRenderer( + false, // gitlabSupportsCommonMark + false, // disableApplyAll + false, // disableApply + false, // disableMarkdownFolding + false, // disableRepoLocking + false, // enableDiffMarkdownFormat + "", // markdownTemplateOverridesDir + "atlantis", // executableName + true, // hideUnchangedPlanComments + false, // quietPolicyChecks + ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" logger.Info(logText) diff --git a/server/events/mock_workingdir_test.go b/server/events/mock_workingdir_test.go index c11b9e28bf..65d5fc00a7 100644 --- a/server/events/mock_workingdir_test.go +++ b/server/events/mock_workingdir_test.go @@ -4,12 +4,11 @@ package events import ( - "reflect" - "time" - pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" + "reflect" + "time" ) type MockWorkingDir struct { diff --git a/server/events/mocks/mock_event_parsing.go b/server/events/mocks/mock_event_parsing.go index 85403ea95c..e6b72acacd 100644 --- a/server/events/mocks/mock_event_parsing.go +++ b/server/events/mocks/mock_event_parsing.go @@ -5,13 +5,13 @@ package mocks import ( gitea "code.gitea.io/sdk/gitea" - github "github.com/google/go-github/v66/github" + github "github.com/google/go-github/v68/github" azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" gitea0 "github.com/runatlantis/atlantis/server/events/vcs/gitea" logging "github.com/runatlantis/atlantis/server/logging" - go_gitlab "github.com/xanzy/go-gitlab" + go_gitlab "gitlab.com/gitlab-org/api/client-go" "reflect" "time" ) diff --git a/server/events/mocks/mock_github_pull_getter.go b/server/events/mocks/mock_github_pull_getter.go index c904e371e4..c2be8a5fb3 100644 --- a/server/events/mocks/mock_github_pull_getter.go +++ b/server/events/mocks/mock_github_pull_getter.go @@ -4,7 +4,7 @@ package mocks import ( - github "github.com/google/go-github/v66/github" + github "github.com/google/go-github/v68/github" pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" diff --git a/server/events/mocks/mock_gitlab_merge_request_getter.go b/server/events/mocks/mock_gitlab_merge_request_getter.go index dfaa1396b9..2b28aae238 100644 --- a/server/events/mocks/mock_gitlab_merge_request_getter.go +++ b/server/events/mocks/mock_gitlab_merge_request_getter.go @@ -6,7 +6,7 @@ package mocks import ( pegomock "github.com/petergtz/pegomock/v4" logging "github.com/runatlantis/atlantis/server/logging" - go_gitlab "github.com/xanzy/go-gitlab" + go_gitlab "gitlab.com/gitlab-org/api/client-go" "reflect" "time" ) diff --git a/server/events/plan_command_runner.go b/server/events/plan_command_runner.go index c1cc3e81e0..a9652b0cab 100644 --- a/server/events/plan_command_runner.go +++ b/server/events/plan_command_runner.go @@ -114,11 +114,6 @@ func (p *PlanCommandRunner) runAutoplan(ctx *command.Context) { return } - // At this point we are sure Atlantis has work to do, so set commit status to pending - if err := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { - ctx.Log.Warn("unable to update plan commit status: %s", err) - } - // discard previous plans that might not be relevant anymore ctx.Log.Debug("deleting previous plans and locks") p.deletePlans(ctx) @@ -188,10 +183,6 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { } } - if err = p.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.PendingCommitStatus, command.Plan); err != nil { - ctx.Log.Warn("unable to update commit status: %s", err) - } - projectCmds, err := p.prjCmdBuilder.BuildPlanCommands(ctx, cmd) if err != nil { if statusErr := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); statusErr != nil { diff --git a/server/events/plan_command_runner_test.go b/server/events/plan_command_runner_test.go index c0085dc963..b1da0012e1 100644 --- a/server/events/plan_command_runner_test.go +++ b/server/events/plan_command_runner_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/events" @@ -472,7 +472,7 @@ func TestPlanCommandRunner_ExecutionOrder(t *testing.T) { scopeNull, _, _ := metrics.NewLoggingScope(logger, "atlantis") pull := &github.PullRequest{ - State: github.String("open"), + State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} diff --git a/server/events/pre_workflow_hooks_command_runner.go b/server/events/pre_workflow_hooks_command_runner.go index 7d152c7328..be175d0b7d 100644 --- a/server/events/pre_workflow_hooks_command_runner.go +++ b/server/events/pre_workflow_hooks_command_runner.go @@ -69,18 +69,6 @@ func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks(ctx *command.Context, escapedArgs = escapeArgs(cmd.Flags) } - // Update the plan or apply commit status to pending whilst the pre workflow hook is running - switch cmd.Name { - case command.Plan: - if err := w.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { - ctx.Log.Warn("unable to update plan commit status: %s", err) - } - case command.Apply: - if err := w.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Apply); err != nil { - ctx.Log.Warn("unable to update apply commit status: %s", err) - } - } - err = w.runHooks( models.WorkflowHookCommandContext{ BaseRepo: ctx.Pull.BaseRepo, diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index c52dee6360..f626d4b605 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -11,7 +11,7 @@ import ( tally "github.com/uber-go/tally/v4" "github.com/runatlantis/atlantis/server/core/config/valid" - "github.com/runatlantis/atlantis/server/core/terraform" + "github.com/runatlantis/atlantis/server/core/terraform/tfclient" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" @@ -59,7 +59,7 @@ func NewInstrumentedProjectCommandBuilder( IncludeGitUntrackedFiles bool, AutoDiscoverMode string, scope tally.Scope, - terraformClient terraform.Client, + terraformClient tfclient.Client, ) *InstrumentedProjectCommandBuilder { scope = scope.SubScope("builder") @@ -119,7 +119,7 @@ func NewProjectCommandBuilder( IncludeGitUntrackedFiles bool, AutoDiscoverMode string, scope tally.Scope, - terraformClient terraform.Client, + terraformClient tfclient.Client, ) *DefaultProjectCommandBuilder { return &DefaultProjectCommandBuilder{ ParserValidator: parserValidator, @@ -249,7 +249,7 @@ type DefaultProjectCommandBuilder struct { // User config option: Controls auto-discovery of projects in a repository. AutoDiscoverMode string // Handles the actual running of Terraform commands. - TerraformExecutor terraform.Client + TerraformExecutor tfclient.Client } // See ProjectCommandBuilder.BuildAutoplanCommands. @@ -318,71 +318,153 @@ func (p *DefaultProjectCommandBuilder) BuildStateRmCommands(ctx *command.Context return p.buildProjectCommand(ctx, cmd) } -// buildAllCommandsByCfg builds init contexts for all projects we determine were -// modified in this ctx. -func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Context, cmdName command.Name, subCmdName string, commentFlags []string, verbose bool) ([]command.ProjectContext, error) { - // We'll need the list of modified files. - modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull) +// shouldSkipClone determines whether we should skip cloning for a given context +func (p *DefaultProjectCommandBuilder) shouldSkipClone(ctx *command.Context, modifiedFiles []string) (bool, error) { + // NOTE: We discard this work here and end up doing it again after + // cloning to ensure all the return values are set properly with + // the actual clone directory. + + if !p.SkipCloneNoChanges || !p.VCSClient.SupportsSingleFileDownload(ctx.Pull.BaseRepo) { + return false, nil + } + repoCfgFile := p.GlobalCfg.RepoConfigFile(ctx.Pull.BaseRepo.ID()) + hasRepoCfg, repoCfgData, err := p.VCSClient.GetFileContent(ctx.Log, ctx.Pull, repoCfgFile) if err != nil { - return nil, err + return false, errors.Wrapf(err, "downloading %s", repoCfgFile) + } + // We can only skip if we determine that none of the modified files belong to projects configured in a repo config + if !hasRepoCfg { + return false, nil + } + repoCfg, err := p.ParserValidator.ParseRepoCfgData(repoCfgData, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) + if err != nil { + return false, errors.Wrapf(err, "parsing %s", repoCfgFile) } + ctx.Log.Info("successfully parsed remote %s file", repoCfgFile) - if p.IncludeGitUntrackedFiles { - ctx.Log.Debug(("'include-git-untracked-files' option is set, getting untracked files")) - untrackedFiles, err := p.WorkingDir.GetGitUntrackedFiles(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace) - if err != nil { - return nil, err - } - modifiedFiles = append(modifiedFiles, untrackedFiles...) + // If auto discover is enabled, we never want to skip cloning + if p.autoDiscoverModeEnabled(ctx, repoCfg) { + ctx.Log.Info("automatic project discovery enabled. Will resume automatic detection") + return false, nil } - ctx.Log.Debug("%d files were modified in this pull request. Modified files: %v", len(modifiedFiles), modifiedFiles) + if len(repoCfg.Projects) == 0 { + ctx.Log.Info("no projects are defined in %s. Will resume automatic detection", repoCfgFile) + return false, nil + } + + matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, repoCfg, "", nil) + if err != nil { + return false, err + } - // Get default AutoDiscoverMode from userConfig/globalConfig + ctx.Log.Info("%d projects are changed on MR %d based on their when_modified config", len(matchingProjects), ctx.Pull.Num) + if len(matchingProjects) == 0 { + ctx.Log.Info("skipping repo clone since no project was modified") + return true, nil + } + + return false, nil + +} + +// autoDiscoverModeEnabled determines whether to use autodiscover +func (p *DefaultProjectCommandBuilder) autoDiscoverModeEnabled(ctx *command.Context, repoCfg valid.RepoCfg) bool { defaultAutoDiscoverMode := valid.AutoDiscoverMode(p.AutoDiscoverMode) globalAutoDiscover := p.GlobalCfg.RepoAutoDiscoverCfg(ctx.Pull.BaseRepo.ID()) if globalAutoDiscover != nil { defaultAutoDiscoverMode = globalAutoDiscover.Mode } + return repoCfg.AutoDiscoverEnabled(defaultAutoDiscoverMode) +} + +// getMergedProjectCfgs gets all merged project configs for building commands given a context and a clone repo +func (p *DefaultProjectCommandBuilder) getMergedProjectCfgs(ctx *command.Context, repoDir string, modifiedFiles []string, repoCfg valid.RepoCfg) ([]valid.MergedProjectCfg, error) { + mergedCfgs := make([]valid.MergedProjectCfg, 0) - if p.SkipCloneNoChanges && p.VCSClient.SupportsSingleFileDownload(ctx.Pull.BaseRepo) { - repoCfgFile := p.GlobalCfg.RepoConfigFile(ctx.Pull.BaseRepo.ID()) - hasRepoCfg, repoCfgData, err := p.VCSClient.GetFileContent(ctx.Log, ctx.Pull, repoCfgFile) + moduleInfo, err := FindModuleProjects(repoDir, p.AutoDetectModuleFiles) + if err != nil { + ctx.Log.Warn("error(s) loading project module dependencies: %s", err) + } + ctx.Log.Debug("moduleInfo for '%s' (matching '%s') = %v", repoDir, p.AutoDetectModuleFiles, moduleInfo) + + if len(repoCfg.Projects) > 0 { + matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, repoCfg, repoDir, moduleInfo) if err != nil { - return nil, errors.Wrapf(err, "downloading %s", repoCfgFile) + return nil, err } + ctx.Log.Info("%d projects are to be planned based on their when_modified config", len(matchingProjects)) - if hasRepoCfg { - repoCfg, err := p.ParserValidator.ParseRepoCfgData(repoCfgData, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) - if err != nil { - return nil, errors.Wrapf(err, "parsing %s", repoCfgFile) - } - ctx.Log.Info("successfully parsed remote %s file", repoCfgFile) + for _, mp := range matchingProjects { + ctx.Log.Debug("determining config for project at dir: '%s' workspace: '%s'", mp.Dir, mp.Workspace) + mergedCfg := p.GlobalCfg.MergeProjectCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), mp, repoCfg) + mergedCfgs = append(mergedCfgs, mergedCfg) + } + } + + if p.autoDiscoverModeEnabled(ctx, repoCfg) { + ctx.Log.Info("automatic project discovery enabled. Will run automatic detection") - if repoCfg.AutoDiscover != nil { - defaultAutoDiscoverMode = repoCfg.AutoDiscover.Mode + // build a module index for projects that are explicitly included + allModifiedProjects := p.ProjectFinder.DetermineProjects( + ctx.Log, modifiedFiles, ctx.Pull.BaseRepo.FullName, repoDir, p.AutoplanFileList, moduleInfo) + // If a project is already manually configured with the same dir as a discovered project, the manually configured + // project should take precedence + modifiedProjects := make([]models.Project, 0) + configuredProjDirs := make(map[string]bool) + // We compare against all configured projects instead of projects which match the modified files in case a + // project is being specifically excluded (ex: when_modified doesn't match). We don't want to accidentally + // "discover" it again. + for _, configProj := range repoCfg.Projects { + // Clean the path to make sure ./rel_path is equivalent to rel_path, etc + configuredProjDirs[filepath.Clean(configProj.Dir)] = true + } + for _, mp := range allModifiedProjects { + path := filepath.Clean(mp.Path) + if repoCfg.IsPathIgnoredForAutoDiscover(path) { + continue } - // If auto discover is enabled, we never want to skip cloning - if !repoCfg.AutoDiscoverEnabled(defaultAutoDiscoverMode) { - if len(repoCfg.Projects) > 0 { - matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, repoCfg, "", nil) - if err != nil { - return nil, err - } - ctx.Log.Info("%d projects are changed on MR %d based on their when_modified config", len(matchingProjects), ctx.Pull.Num) - if len(matchingProjects) == 0 { - ctx.Log.Info("skipping repo clone since no project was modified") - return []command.ProjectContext{}, nil - } - } else { - ctx.Log.Info("no projects are defined in %s. Will resume automatic detection", repoCfgFile) - } - } else { - ctx.Log.Info("automatic project discovery enabled. Will resume automatic detection") + _, dirExists := configuredProjDirs[path] + if !dirExists { + modifiedProjects = append(modifiedProjects, mp) } - // NOTE: We discard this work here and end up doing it again after - // cloning to ensure all the return values are set properly with - // the actual clone directory. + } + ctx.Log.Info("automatically determined that there were %d additional projects modified in this pull request: %s", + len(modifiedProjects), modifiedProjects) + for _, mp := range modifiedProjects { + ctx.Log.Debug("determining config for project at dir: '%s'", mp.Path) + absProjectDir := filepath.Join(repoDir, mp.Path) + pWorkspace, err := p.ProjectFinder.DetermineWorkspaceFromHCL(ctx.Log, absProjectDir) + if err != nil { + return nil, errors.Wrapf(err, "Looking for Terraform Cloud workspace from configuration in '%s'", absProjectDir) + } + + pCfg := p.GlobalCfg.DefaultProjCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), mp.Path, pWorkspace) + mergedCfgs = append(mergedCfgs, pCfg) + } + } + return mergedCfgs, nil +} + +// buildAllCommandsByCfg builds init contexts for all projects we determine were +// modified in this ctx. +func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Context, cmdName command.Name, subCmdName string, commentFlags []string, verbose bool) ([]command.ProjectContext, error) { + // We'll need the list of modified files. + modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull) + if err != nil { + return nil, err + } + + ctx.Log.Debug("%d files were modified in this pull request. Modified files: %v", len(modifiedFiles), modifiedFiles) + + // If we're not including git untracked files, we can skip the clone if there are no modified files. + if !p.IncludeGitUntrackedFiles { + shouldSkipClone, err := p.shouldSkipClone(ctx, modifiedFiles) + if err != nil { + return nil, err + } + if shouldSkipClone { + return []command.ProjectContext{}, nil } } @@ -402,6 +484,15 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex return nil, err } + if p.IncludeGitUntrackedFiles { + ctx.Log.Debug(("'include-git-untracked-files' option is set, getting untracked files")) + untrackedFiles, err := p.WorkingDir.GetGitUntrackedFiles(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace) + if err != nil { + return nil, err + } + modifiedFiles = append(modifiedFiles, untrackedFiles...) + } + // Parse config file if it exists. repoCfgFile := p.GlobalCfg.RepoConfigFile(ctx.Pull.BaseRepo.ID()) hasRepoCfg, err := p.ParserValidator.HasRepoCfg(repoDir, repoCfgFile) @@ -420,22 +511,14 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex return nil, errors.Wrapf(err, "parsing %s", repoCfgFile) } ctx.Log.Info("successfully parsed %s file", repoCfgFile) - // It's possible we've already set defaultAutoDiscoverMode - // from the config file while checking whether we can skip - // cloning. We still need to set it here in the case that - // we were not able to check whether we can skip cloning - // and thus were not able to previously fetch the repo - // config. - if repoCfg.AutoDiscover != nil { - defaultAutoDiscoverMode = repoCfg.AutoDiscover.Mode - } + } else { + ctx.Log.Info("repo config file %s is absent, using global defaults", repoCfg) } - moduleInfo, err := FindModuleProjects(repoDir, p.AutoDetectModuleFiles) + mergedProjectCfgs, err := p.getMergedProjectCfgs(ctx, repoDir, modifiedFiles, repoCfg) if err != nil { - ctx.Log.Warn("error(s) loading project module dependencies: %s", err) + return nil, err } - ctx.Log.Debug("moduleInfo for '%s' (matching '%s') = %v", repoDir, p.AutoDetectModuleFiles, moduleInfo) automerge := p.EnableAutoMerge parallelApply := p.EnableParallelApply @@ -454,95 +537,22 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex abortOnExecutionOrderFail = repoCfg.AbortOnExecutionOrderFail } - if len(repoCfg.Projects) > 0 { - matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, repoCfg, repoDir, moduleInfo) - if err != nil { - return nil, err - } - ctx.Log.Info("%d projects are to be planned based on their when_modified config", len(matchingProjects)) - - for _, mp := range matchingProjects { - ctx.Log.Debug("determining config for project at dir: '%s' workspace: '%s'", mp.Dir, mp.Workspace) - mergedCfg := p.GlobalCfg.MergeProjectCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), mp, repoCfg) - - projCtxs = append(projCtxs, - p.ProjectCommandContextBuilder.BuildProjectContext( - ctx, - cmdName, - subCmdName, - mergedCfg, - commentFlags, - repoDir, - automerge, - parallelApply, - parallelPlan, - verbose, - abortOnExecutionOrderFail, - p.TerraformExecutor, - )...) - } - } - - if repoCfg.AutoDiscoverEnabled(defaultAutoDiscoverMode) { - // If there is no config file or it specified no projects, then we'll plan each project that - // our algorithm determines was modified. - if hasRepoCfg { - if len(repoCfg.Projects) == 0 { - ctx.Log.Info("no projects are defined in %s. Will resume automatic detection", repoCfgFile) - } else { - ctx.Log.Info("automatic project discovery enabled. Will resume automatic detection") - } - } else { - ctx.Log.Info("found no %s file", repoCfgFile) - } - // build a module index for projects that are explicitly included - allModifiedProjects := p.ProjectFinder.DetermineProjects( - ctx.Log, modifiedFiles, ctx.Pull.BaseRepo.FullName, repoDir, p.AutoplanFileList, moduleInfo) - // If a project is already manually configured with the same dir as a discovered project, the manually configured - // project should take precedence - modifiedProjects := make([]models.Project, 0) - configuredProjDirs := make(map[string]bool) - // We compare against all configured projects instead of projects which match the modified files in case a - // project is being specifically excluded (ex: when_modified doesn't match). We don't want to accidentally - // "discover" it again. - for _, configProj := range repoCfg.Projects { - // Clean the path to make sure ./rel_path is equivalent to rel_path, etc - configuredProjDirs[filepath.Clean(configProj.Dir)] = true - } - for _, mp := range allModifiedProjects { - _, dirExists := configuredProjDirs[filepath.Clean(mp.Path)] - if !dirExists { - modifiedProjects = append(modifiedProjects, mp) - } - } - ctx.Log.Info("automatically determined that there were %d additional projects modified in this pull request: %s", - len(modifiedProjects), modifiedProjects) - for _, mp := range modifiedProjects { - ctx.Log.Debug("determining config for project at dir: '%s'", mp.Path) - absProjectDir := filepath.Join(repoDir, mp.Path) - pWorkspace, err := p.ProjectFinder.DetermineWorkspaceFromHCL(ctx.Log, absProjectDir) - if err != nil { - return nil, errors.Wrapf(err, "Looking for Terraform Cloud workspace from configuration in '%s'", absProjectDir) - } - - pCfg := p.GlobalCfg.DefaultProjCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), mp.Path, pWorkspace) - - projCtxs = append(projCtxs, - p.ProjectCommandContextBuilder.BuildProjectContext( - ctx, - cmdName, - subCmdName, - pCfg, - commentFlags, - repoDir, - automerge, - parallelApply, - parallelPlan, - verbose, - abortOnExecutionOrderFail, - p.TerraformExecutor, - )...) - } + for _, mergedProjectCfg := range mergedProjectCfgs { + projCtxs = append(projCtxs, + p.ProjectCommandContextBuilder.BuildProjectContext( + ctx, + cmdName, + subCmdName, + mergedProjectCfg, + commentFlags, + repoDir, + automerge, + parallelApply, + parallelPlan, + verbose, + abortOnExecutionOrderFail, + p.TerraformExecutor, + )...) } sort.Slice(projCtxs, func(i, j int) bool { diff --git a/server/events/project_command_builder_internal_test.go b/server/events/project_command_builder_internal_test.go index d020871b31..115657e38e 100644 --- a/server/events/project_command_builder_internal_test.go +++ b/server/events/project_command_builder_internal_test.go @@ -9,7 +9,7 @@ import ( . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" - "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" @@ -648,7 +648,7 @@ projects: Ok(t, os.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600)) } - terraformClient := mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, @@ -865,7 +865,7 @@ projects: statsScope, _, _ := metrics.NewLoggingScope(logging.NewNoopLogger(t), "atlantis") - terraformClient := mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, @@ -1112,7 +1112,7 @@ workflows: } statsScope, _, _ := metrics.NewLoggingScope(logging.NewNoopLogger(t), "atlantis") - terraformClient := mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( true, @@ -1264,7 +1264,7 @@ projects: } statsScope, _, _ := metrics.NewLoggingScope(logging.NewNoopLogger(t), "atlantis") - terraformClient := mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, @@ -1406,7 +1406,7 @@ projects: } statsScope, _, _ := metrics.NewLoggingScope(logging.NewNoopLogger(t), "atlantis") - terraformClient := mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index 7560b5d6de..e74c563ed7 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" - terraform_mocks "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" @@ -45,7 +45,7 @@ var defaultUserConfig = struct { AutoplanFileList: "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", RestrictFileList: false, SilenceNoProjects: false, - IncludeGitUntrackedFiles: true, + IncludeGitUntrackedFiles: false, AutoDiscoverMode: "auto", } @@ -233,7 +233,7 @@ terraform { scope, _, _ := metrics.NewLoggingScope(logger, "atlantis") userConfig := defaultUserConfig - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() for _, c := range cases { t.Run(c.Description, func(t *testing.T) { @@ -616,7 +616,7 @@ projects: AllowAllRepoSettings: true, } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -804,7 +804,7 @@ projects: AllowAllRepoSettings: true, } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -1078,6 +1078,78 @@ projects: }, }, }, + "autodiscover enabled but project excluded by autodiscover ignore": { + DirStructure: map[string]interface{}{ + "project1": map[string]interface{}{ + "main.tf": nil, + }, + "project2": map[string]interface{}{ + "main.tf": nil, + }, + "project3": map[string]interface{}{ + "main.tf": nil, + }, + }, + AtlantisYAML: `version: 3 +autodiscover: + mode: enabled + ignore_paths: + - project3 +projects: +- name: project1-custom-name + dir: project1`, + ModifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, + // project2 is autodiscovered, but autodiscover was ignored for project3 + // project1 is configured explicitly so added + Exp: []expCtxFields{ + { + ProjectName: "project1-custom-name", + RepoRelDir: "project1", + Workspace: "default", + }, + { + ProjectName: "", + RepoRelDir: "project2", + Workspace: "default", + }, + }, + }, + "autodiscover enabled but ignoring explicit project": { + DirStructure: map[string]interface{}{ + "project1": map[string]interface{}{ + "main.tf": nil, + }, + "project2": map[string]interface{}{ + "main.tf": nil, + }, + "project3": map[string]interface{}{ + "main.tf": nil, + }, + }, + AtlantisYAML: `version: 3 +autodiscover: + mode: enabled + ignore_paths: + - project1 +projects: +- name: project1-custom-name + dir: project1`, + ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, + // project2 is autodiscover-ignored, but configured explicitly so added + // project1 is autodiscoverd as normal + Exp: []expCtxFields{ + { + ProjectName: "project1-custom-name", + RepoRelDir: "project1", + Workspace: "default", + }, + { + ProjectName: "", + RepoRelDir: "project2", + Workspace: "default", + }, + }, + }, "autodiscover enabled but project excluded by empty when_modified": { DirStructure: map[string]interface{}{ "project1": map[string]interface{}{ @@ -1133,7 +1205,7 @@ projects: AllowAllRepoSettings: true, } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -1231,7 +1303,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) { globalCfgArgs := valid.GlobalCfgArgs{} scope, _, _ := metrics.NewLoggingScope(logger, "atlantis") - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -1317,7 +1389,7 @@ projects: scope, _, _ := metrics.NewLoggingScope(logger, "atlantis") userConfig := defaultUserConfig - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -1405,7 +1477,7 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) { AllowAllRepoSettings: true, } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -1558,7 +1630,7 @@ projects: AllowAllRepoSettings: true, } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() When(terraformClient.DetectVersion(Any[logging.SimpleLogging](), Any[string]())).Then(func(params []Param) ReturnValues { projectName := filepath.Base(params[1].(string)) testVersion := testCase.Exp[projectName] @@ -1623,27 +1695,40 @@ projects: // Test that we don't clone the repo if there were no changes based on the atlantis.yaml file. func TestDefaultProjectCommandBuilder_SkipCloneNoChanges(t *testing.T) { cases := []struct { - AtlantisYAML string - ExpectedCtxs int - ExpectedClones InvocationCountMatcher - ModifiedFiles []string + AtlantisYAML string + ExpectedCtxs int + ExpectedClones InvocationCountMatcher + ModifiedFiles []string + IncludeGitUntrackedFiles bool }{ { AtlantisYAML: ` version: 3 projects: - dir: dir1`, - ExpectedCtxs: 0, - ExpectedClones: Never(), - ModifiedFiles: []string{"dir2/main.tf"}, + ExpectedCtxs: 0, + ExpectedClones: Never(), + ModifiedFiles: []string{"dir2/main.tf"}, + IncludeGitUntrackedFiles: false, + }, + { + AtlantisYAML: ` +version: 3 +projects: +- dir: dir1`, + ExpectedCtxs: 0, + ExpectedClones: Once(), + ModifiedFiles: []string{"dir2/main.tf"}, + IncludeGitUntrackedFiles: true, }, { AtlantisYAML: ` version: 3 parallel_plan: true`, - ExpectedCtxs: 0, - ExpectedClones: Once(), - ModifiedFiles: []string{"README.md"}, + ExpectedCtxs: 0, + ExpectedClones: Once(), + ModifiedFiles: []string{"README.md"}, + IncludeGitUntrackedFiles: false, }, { AtlantisYAML: ` @@ -1652,9 +1737,10 @@ autodiscover: mode: enabled projects: - dir: dir1`, - ExpectedCtxs: 0, - ExpectedClones: Once(), - ModifiedFiles: []string{"dir2/main.tf"}, + ExpectedCtxs: 0, + ExpectedClones: Once(), + ModifiedFiles: []string{"dir2/main.tf"}, + IncludeGitUntrackedFiles: false, }, } @@ -1677,7 +1763,7 @@ projects: AllowAllRepoSettings: true, } scope, _, _ := metrics.NewLoggingScope(logger, "atlantis") - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -1698,7 +1784,7 @@ projects: userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, - userConfig.IncludeGitUntrackedFiles, + c.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, @@ -1746,7 +1832,7 @@ func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanComman } globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( true, @@ -1834,7 +1920,7 @@ func TestDefaultProjectCommandBuilder_BuildVersionCommand(t *testing.T) { globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, @@ -1964,7 +2050,7 @@ func TestDefaultProjectCommandBuilder_BuildPlanCommands_Single_With_RestrictFile Ok(t, err) } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, // policyChecksSupported @@ -2075,7 +2161,7 @@ func TestDefaultProjectCommandBuilder_BuildPlanCommands_with_IncludeGitUntracked Ok(t, err) } - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, // policyChecksSupported diff --git a/server/events/project_command_context_builder.go b/server/events/project_command_context_builder.go index 509fa728b8..8c1fe76516 100644 --- a/server/events/project_command_context_builder.go +++ b/server/events/project_command_context_builder.go @@ -5,7 +5,7 @@ import ( "github.com/google/uuid" "github.com/runatlantis/atlantis/server/core/config/valid" - "github.com/runatlantis/atlantis/server/core/terraform" + "github.com/runatlantis/atlantis/server/core/terraform/tfclient" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" tally "github.com/uber-go/tally/v4" @@ -38,7 +38,7 @@ type ProjectCommandContextBuilder interface { prjCfg valid.MergedProjectCfg, commentFlags []string, repoDir string, - automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, terraformClient terraform.Client, + automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, terraformClient tfclient.Client, ) []command.ProjectContext } @@ -59,7 +59,7 @@ func (cb *CommandScopedStatsProjectCommandContextBuilder) BuildProjectContext( commentFlags []string, repoDir string, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, - terraformClient terraform.Client, + terraformClient tfclient.Client, ) (projectCmds []command.ProjectContext) { cb.ProjectCounter.Inc(1) @@ -93,7 +93,7 @@ func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext( commentFlags []string, repoDir string, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, - terraformClient terraform.Client, + terraformClient tfclient.Client, ) (projectCmds []command.ProjectContext) { ctx.Log.Debug("Building project command context for %s", cmdName) @@ -166,7 +166,7 @@ func (cb *PolicyCheckProjectCommandContextBuilder) BuildProjectContext( commentFlags []string, repoDir string, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, - terraformClient terraform.Client, + terraformClient tfclient.Client, ) (projectCmds []command.ProjectContext) { if prjCfg.PolicyCheck { ctx.Log.Debug("PolicyChecks are enabled") @@ -297,6 +297,7 @@ func newProjectCommandContext(ctx *command.Context, RePlanCmd: planCmd, RepoRelDir: projCfg.RepoRelDir, RepoConfigVersion: projCfg.RepoCfgVersion, + TerraformDistribution: projCfg.TerraformDistribution, TerraformVersion: projCfg.TerraformVersion, User: ctx.User, Verbose: verbose, diff --git a/server/events/project_command_context_builder_test.go b/server/events/project_command_context_builder_test.go index ff40645e0a..5e66cdb4a2 100644 --- a/server/events/project_command_context_builder_test.go +++ b/server/events/project_command_context_builder_test.go @@ -5,7 +5,7 @@ import ( . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" - terraform_mocks "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" @@ -47,7 +47,7 @@ func TestProjectCommandContextBuilder_PullStatus(t *testing.T) { expectedApplyCmt := "Apply Comment" expectedPlanCmt := "Plan Comment" - terraformClient := terraform_mocks.NewMockClient() + terraformClient := tfclientmocks.NewMockClient() t.Run("with project name defined", func(t *testing.T) { When(mockCommentBuilder.BuildPlanComment(projRepoRelDir, projWorkspace, projName, []string{})).ThenReturn(expectedPlanCmt) diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 76f9ba9202..0558fe35aa 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -225,6 +225,7 @@ type DefaultProjectCommandRunner struct { VcsClient vcs.Client Locker ProjectLocker LockURLGenerator LockURLGenerator + Logger logging.SimpleLogging InitStepRunner StepRunner PlanStepRunner StepRunner ShowStepRunner StepRunner @@ -367,7 +368,7 @@ func (p *DefaultProjectCommandRunner) doApprovePolicies(ctx command.ProjectConte // Only query the users team membership if any teams have been configured as owners on any policy set(s). if policySetCfg.HasTeamOwners() { // A convenient way to access vcsClient. Not sure if best way. - userTeams, err := p.VcsClient.GetTeamNamesForUser(ctx.Pull.BaseRepo, ctx.User) + userTeams, err := p.VcsClient.GetTeamNamesForUser(p.Logger, ctx.Pull.BaseRepo, ctx.User) if err != nil { ctx.Log.Err("unable to get team membership for user: %s", err) return nil, "", err @@ -660,12 +661,13 @@ func (p *DefaultProjectCommandRunner) doApply(ctx command.ProjectContext) (apply outputs, err := p.runSteps(ctx.Steps, ctx, absPath) p.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck - Workspace: ctx.Workspace, - User: ctx.User, - Repo: ctx.Pull.BaseRepo, - Pull: ctx.Pull, - Success: err == nil, - Directory: ctx.RepoRelDir, + Workspace: ctx.Workspace, + User: ctx.User, + Repo: ctx.Pull.BaseRepo, + Pull: ctx.Pull, + Success: err == nil, + Directory: ctx.RepoRelDir, + ProjectName: ctx.ProjectName, }) if err != nil { diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go index 13a75a1658..382bda6d18 100644 --- a/server/events/project_command_runner_test.go +++ b/server/events/project_command_runner_test.go @@ -23,7 +23,9 @@ import ( . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime" + "github.com/runatlantis/atlantis/server/core/terraform" tmocks "github.com/runatlantis/atlantis/server/core/terraform/mocks" + tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" @@ -542,12 +544,14 @@ func TestDefaultProjectCommandRunner_ApplyRunStepFailure(t *testing.T) { // not running any Terraform. func TestDefaultProjectCommandRunner_RunEnvSteps(t *testing.T) { RegisterMockTestingT(t) - tfClient := tmocks.NewMockClient() + tfClient := tfclientmocks.NewMockClient() + tfDistribution := terraform.NewDistributionTerraformWithDownloader(tmocks.NewMockDownloader()) tfVersion, err := version.NewVersion("0.12.0") Ok(t, err) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() run := runtime.RunStepRunner{ TerraformExecutor: tfClient, + DefaultTFDistribution: tfDistribution, DefaultTFVersion: tfVersion, ProjectCmdOutputHandler: projectCmdOutputHandler, } @@ -1276,7 +1280,7 @@ func TestDefaultProjectCommandRunner_ApprovePolicies(t *testing.T) { } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num, Author: testdata.User.Username} - When(runner.VcsClient.GetTeamNamesForUser(testdata.GithubRepo, testdata.User)).ThenReturn(c.userTeams, nil) + When(runner.VcsClient.GetTeamNamesForUser(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.User))).ThenReturn(c.userTeams, nil) ctx := command.ProjectContext{ User: testdata.User, Log: logging.NewNoopLogger(t), diff --git a/server/events/templates/multi_project_policy.tmpl b/server/events/templates/multi_project_policy.tmpl index add574fde4..276dfe2b72 100644 --- a/server/events/templates/multi_project_policy.tmpl +++ b/server/events/templates/multi_project_policy.tmpl @@ -2,8 +2,10 @@ {{ template "multiProjectHeader" . -}} {{ $disableApplyAll := .DisableApplyAll -}} {{ $hideUnchangedPlans := .HideUnchangedPlanComments -}} +{{ $quietPolicyChecks := .QuietPolicyChecks -}} {{ range $i, $result := .Results -}} {{ if (and $hideUnchangedPlans $result.NoChanges) }}{{continue}}{{end -}} +{{ if (and $quietPolicyChecks $result.IsSuccessful) }}{{continue}}{{end -}} ### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} diff --git a/server/events/templates/multi_project_policy_unsuccessful.tmpl b/server/events/templates/multi_project_policy_unsuccessful.tmpl index 039dd9ce7c..7e11821dfd 100644 --- a/server/events/templates/multi_project_policy_unsuccessful.tmpl +++ b/server/events/templates/multi_project_policy_unsuccessful.tmpl @@ -1,7 +1,9 @@ {{ define "multiProjectPolicyUnsuccessful" -}} {{ template "multiProjectHeader" . -}} {{ $disableApplyAll := .DisableApplyAll -}} +{{ $quietPolicyChecks := .QuietPolicyChecks -}} {{ range $i, $result := .Results -}} +{{ if (and $quietPolicyChecks $result.IsSuccessful) }}{{continue}}{{end -}} ### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go index 35c303bae2..77ccf948e5 100644 --- a/server/events/vcs/azuredevops_client.go +++ b/server/events/vcs/azuredevops_client.go @@ -393,7 +393,7 @@ func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project st } // GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). -func (g *AzureDevopsClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { //nolint: revive +func (g *AzureDevopsClient) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { //nolint: revive return nil, nil } diff --git a/server/events/vcs/bitbucketcloud/client.go b/server/events/vcs/bitbucketcloud/client.go index b777030237..e56f2eb9f5 100644 --- a/server/events/vcs/bitbucketcloud/client.go +++ b/server/events/vcs/bitbucketcloud/client.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "unicode/utf8" validator "github.com/go-playground/validator/v10" @@ -39,6 +40,8 @@ func NewClient(httpClient *http.Client, username string, password string, atlant } } +var MY_UUID = "" + // GetModifiedFiles returns the names of files that were modified in the merge request // relative to the repo root, e.g. parent/child/file.txt. func (b *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { @@ -107,10 +110,92 @@ func (b *Client) ReactToComment(_ logging.SimpleLogging, _ models.Repo, _ int, _ return nil } -func (b *Client) HidePrevCommandComments(_ logging.SimpleLogging, _ models.Repo, _ int, _ string, _ string) error { +func (b *Client) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, _ string) error { + // there is no way to hide comment, so delete them instead + me, err := b.GetMyUUID() + if err != nil { + return errors.Wrapf(err, "Cannot get my uuid! Please check required scope of the auth token!") + } + logger.Debug("My bitbucket user UUID is: %s", me) + + comments, err := b.GetPullRequestComments(repo, pullNum) + if err != nil { + return err + } + + for _, c := range comments { + logger.Debug("Comment is %v", c.Content.Raw) + if strings.EqualFold(*c.User.UUID, me) { + // do the same crude filtering as github client does + body := strings.Split(c.Content.Raw, "\n") + logger.Debug("Body is %s", body) + if len(body) == 0 { + continue + } + firstLine := strings.ToLower(body[0]) + if strings.Contains(firstLine, strings.ToLower(command)) { + // we found our old comment that references that command + logger.Debug("Deleting comment with id %s", *c.ID) + err = b.DeletePullRequestComment(repo, pullNum, *c.ID) + if err != nil { + return err + } + } + } + } + return nil +} + +func (b *Client) DeletePullRequestComment(repo models.Repo, pullNum int, commentId int) error { + path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments/%d", b.BaseURL, repo.FullName, pullNum, commentId) + _, err := b.makeRequest("DELETE", path, nil) + if err != nil { + return err + } return nil } +func (b *Client) GetPullRequestComments(repo models.Repo, pullNum int) (comments []PullRequestComment, err error) { + path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments", b.BaseURL, repo.FullName, pullNum) + res, err := b.makeRequest("GET", path, nil) + if err != nil { + return comments, err + } + + var pulls PullRequestComments + if err := json.Unmarshal(res, &pulls); err != nil { + return comments, errors.Wrapf(err, "Could not parse response %q", string(res)) + } + return pulls.Values, nil +} + +func (b *Client) GetMyUUID() (uuid string, err error) { + if MY_UUID == "" { + path := fmt.Sprintf("%s/2.0/user", b.BaseURL) + resp, err := b.makeRequest("GET", path, nil) + + if err != nil { + return uuid, err + } + + var user User + if err := json.Unmarshal(resp, &user); err != nil { + return uuid, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + + if err := validator.New().Struct(user); err != nil { + return uuid, errors.Wrapf(err, "API response %q was missing a field", string(resp)) + } + + uuid = *user.UUID + MY_UUID = uuid + + return uuid, nil + } else { + return MY_UUID, nil + } +} + // PullIsApproved returns true if the merge request was approved. func (b *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) { path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d", b.BaseURL, repo.FullName, pull.Num) @@ -254,7 +339,7 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b defer resp.Body.Close() // nolint: errcheck requestStr := fmt.Sprintf("%s %s", method, path) - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody)) } @@ -266,7 +351,7 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b } // GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). -func (b *Client) GetTeamNamesForUser(_ models.Repo, _ models.User) ([]string, error) { +func (b *Client) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { return nil, nil } diff --git a/server/events/vcs/bitbucketcloud/client_test.go b/server/events/vcs/bitbucketcloud/client_test.go index 59108a14f3..5b5d1ab4af 100644 --- a/server/events/vcs/bitbucketcloud/client_test.go +++ b/server/events/vcs/bitbucketcloud/client_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/runatlantis/atlantis/server/events/models" @@ -367,3 +368,159 @@ func TestClient_MarkdownPullLink(t *testing.T) { exp := "#1" Equals(t, exp, s) } + +func TestClient_GetMyUUID(t *testing.T) { + json, err := os.ReadFile(filepath.Join("testdata", "user.json")) + Ok(t, err) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/2.0/user": + w.Write(json) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + v, _ := client.GetMyUUID() + Equals(t, v, "{00000000-0000-0000-0000-000000000001}") +} + +func TestClient_GetComment(t *testing.T) { + json, err := os.ReadFile(filepath.Join("testdata", "comments.json")) + Ok(t, err) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments": + w.Write(json) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + v, _ := client.GetPullRequestComments( + models.Repo{ + FullName: "myorg/myrepo", + Owner: "owner", + Name: "myrepo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "bitbucket.org", + }, + }, 5) + + Equals(t, len(v), 5) + exp := "Plan" + Assert(t, strings.Contains(v[1].Content.Raw, exp), "Comment should contain word \"%s\", has \"%s\"", exp, v[1].Content.Raw) +} + +func TestClient_DeleteComment(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/1": + if r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + } + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + err := client.DeletePullRequestComment( + models.Repo{ + FullName: "myorg/myrepo", + Owner: "owner", + Name: "myrepo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "bitbucket.org", + }, + }, 5, 1) + Ok(t, err) +} + +func TestClient_HidePRComments(t *testing.T) { + logger := logging.NewNoopLogger(t) + comments, err := os.ReadFile(filepath.Join("testdata", "comments.json")) + Ok(t, err) + json, err := os.ReadFile(filepath.Join("testdata", "user.json")) + Ok(t, err) + + called := 0 + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // we have two comments in the test file + // The code is going to delete them all and then create a new one + case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/498931882": + if r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + } + w.Write([]byte("")) // nolint: errcheck + called += 1 + return + // This is the second one + case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/498931784": + if r.Method == "DELETE" { + http.Error(w, "", http.StatusNoContent) + } + w.Write([]byte("")) // nolint: errcheck + called += 1 + return + case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/49893111": + Assert(t, r.Method != "DELETE", "Shouldn't delete this one") + return + case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments": + w.Write(comments) // nolint: errcheck + return + case "/2.0/user": + w.Write(json) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + err = client.HidePrevCommandComments(logger, + models.Repo{ + FullName: "myorg/myrepo", + Owner: "owner", + Name: "myrepo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "bitbucket.org", + }, + }, 5, "plan", "") + Ok(t, err) + Equals(t, 2, called) +} diff --git a/server/events/vcs/bitbucketcloud/models.go b/server/events/vcs/bitbucketcloud/models.go index 1da27ebbe3..d33fc14da6 100644 --- a/server/events/vcs/bitbucketcloud/models.go +++ b/server/events/vcs/bitbucketcloud/models.go @@ -45,6 +45,34 @@ type Repository struct { FullName *string `json:"full_name,omitempty" validate:"required"` Links Links `json:"links,omitempty" validate:"required"` } + +type User struct { + Type *string `json:"type,omitempty" validate:"required"` + CreateOn *string `json:"created_on" validate:"required"` + DisplayName *string `json:"display_name" validate:"required"` + Username *string `json:"username" validate:"required"` + UUID *string `json:"uuid" validate:"required"` +} + +type UserInComment struct { + Type *string `json:"type,omitempty" validate:"required"` + Nickname *string `json:"nickname" validate:"required"` + DisplayName *string `json:"display_name" validate:"required"` + UUID *string `json:"uuid" validate:"required"` +} + +type PullRequestComment struct { + ID *int `json:"id,omitempty" validate:"required"` + User *UserInComment `json:"user" validate:"required"` + Content *struct { + Raw string `json:"raw"` + } `json:"content" validate:"required"` +} + +type PullRequestComments struct { + Values []PullRequestComment `json:"values,omitempty"` +} + type PullRequest struct { ID *int `json:"id,omitempty" validate:"required"` Source *BranchMeta `json:"source,omitempty" validate:"required"` diff --git a/server/events/vcs/bitbucketcloud/request_validation.go b/server/events/vcs/bitbucketcloud/request_validation.go new file mode 100644 index 0000000000..e4df275bb0 --- /dev/null +++ b/server/events/vcs/bitbucketcloud/request_validation.go @@ -0,0 +1,72 @@ +package bitbucketcloud + +import ( + "crypto/hmac" + "crypto/sha1" // nolint: gosec + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash" + "strings" + + "github.com/pkg/errors" +) + +// Attribution: This code is taken from https://github.com/google/go-github. + +func ValidateSignature(payload []byte, signature string, secretKey []byte) error { + messageMAC, hashFunc, err := messageMAC(signature) + if err != nil { + return err + } + if !checkMAC(payload, messageMAC, secretKey, hashFunc) { + return errors.New("payload signature check failed") + } + return nil +} + +// genMAC generates the HMAC signature for a message provided the secret key +// and hashFunc. +func genMAC(message, key []byte, hashFunc func() hash.Hash) []byte { + mac := hmac.New(hashFunc, key) + // nolint: errcheck + mac.Write(message) + return mac.Sum(nil) +} + +// checkMAC reports whether messageMAC is a valid HMAC tag for message. +func checkMAC(message, messageMAC, key []byte, hashFunc func() hash.Hash) bool { + expectedMAC := genMAC(message, key, hashFunc) + return hmac.Equal(messageMAC, expectedMAC) +} + +// messageMAC returns the hex-decoded HMAC tag from the signature and its +// corresponding hash function. +func messageMAC(signature string) ([]byte, func() hash.Hash, error) { + if signature == "" { + return nil, nil, errors.New("missing signature") + } + sigParts := strings.SplitN(signature, "=", 2) + if len(sigParts) != 2 { + return nil, nil, fmt.Errorf("error parsing signature %q", signature) + } + + var hashFunc func() hash.Hash + switch sigParts[0] { + case "sha1": + hashFunc = sha1.New + case "sha256": + hashFunc = sha256.New + case "sha512": + hashFunc = sha512.New + default: + return nil, nil, fmt.Errorf("unknown hash type prefix: %q", sigParts[0]) + } + + buf, err := hex.DecodeString(sigParts[1]) + if err != nil { + return nil, nil, fmt.Errorf("error decoding signature %q: %v", signature, err) + } + return buf, hashFunc, nil +} diff --git a/server/events/vcs/bitbucketcloud/request_validation_test.go b/server/events/vcs/bitbucketcloud/request_validation_test.go new file mode 100644 index 0000000000..63969a4b12 --- /dev/null +++ b/server/events/vcs/bitbucketcloud/request_validation_test.go @@ -0,0 +1,24 @@ +package bitbucketcloud_test + +import ( + "testing" + + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" + . "github.com/runatlantis/atlantis/testing" +) + +func TestValidateSignature(t *testing.T) { + body := `{"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/master","displayId":"master","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}` + secret := "mysecret" + sig := `sha256=ed11f92d1565a4de586727fe8260558277d58009e8957a79eb4749a7009ce083` + err := bitbucketcloud.ValidateSignature([]byte(body), sig, []byte(secret)) + Ok(t, err) +} + +func TestValidateSignature_Invalid(t *testing.T) { + body := `"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/main","displayId":"main","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}` + secret := "mysecret" + sig := `sha256=ed11f92d1565a4de586727fe8260558277d58009e8957a79eb4749a7009ce083` + err := bitbucketcloud.ValidateSignature([]byte(body), sig, []byte(secret)) + ErrEquals(t, "payload signature check failed", err) +} diff --git a/server/events/vcs/bitbucketcloud/testdata/comments.json b/server/events/vcs/bitbucketcloud/testdata/comments.json new file mode 100644 index 0000000000..746accc259 --- /dev/null +++ b/server/events/vcs/bitbucketcloud/testdata/comments.json @@ -0,0 +1,272 @@ +{ + "values": [ + { + "id": 498931784, + "created_on": "2024-05-07T12:21:45.858898+00:00", + "updated_on": "2024-05-07T12:21:45.859011+00:00", + "content": { + "type": "rendered", + "raw": "atlantis plan", + "markup": "markdown", + "html": "

atlantis plan

" + }, + "user": { + "display_name": "Ragne", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "avatar": { + "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/EM-3.png" + }, + "html": { + "href": "https://bitbucket.org/%7B00000000-0000-0000-0000-000000000001%7D/" + } + }, + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}", + "account_id": "000000:00000000-0000-0000-0000-000000000001", + "nickname": "Ragne" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931784" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931784" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 498931802, + "created_on": "2024-05-07T12:21:48.737851+00:00", + "updated_on": "2024-05-07T12:21:48.737927+00:00", + "content": { + "type": "rendered", + "raw": "Ran Plan for 0 projects:", + "markup": "markdown", + "html": "

Ran Plan for 0 projects:

" + }, + "user": { + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B600000000-0000-0000-0000-000000000000%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/d5c4bac76953df92f47d1dea43fcdba0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FKB-4.png" + }, + "html": { + "href": "https://bitbucket.org/%7B600000000-0000-0000-0000-000000000000%7D/" + } + }, + "type": "user", + "uuid": "{600000000-0000-0000-0000-000000000000}", + "account_id": "00000000-0000-0000-0000-000000000000", + "nickname": "bb bot" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931802" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931802" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 498931882, + "created_on": "2024-05-07T12:22:01.870344+00:00", + "updated_on": "2024-05-07T12:22:01.870462+00:00", + "content": { + "type": "rendered", + "raw": "atlantis plan", + "markup": "markdown", + "html": "

atlantis plan

" + }, + "user": { + "display_name": "Ragne", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "avatar": { + "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/EM-3.png" + }, + "html": { + "href": "https://bitbucket.org/%7B00000000-0000-0000-0000-000000000001%7D/" + } + }, + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}", + "account_id": "000000:00000000-0000-0000-0000-000000000001", + "nickname": "Ragne" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931882" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931882" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 498931901, + "created_on": "2024-05-07T12:22:04.981415+00:00", + "updated_on": "2024-05-07T12:22:04.981490+00:00", + "content": { + "type": "rendered", + "raw": "Ran Plan for 0 projects:", + "markup": "markdown", + "html": "

Ran Plan for 0 projects:

" + }, + "user": { + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B600000000-0000-0000-0000-000000000000%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/d5c4bac76953df92f47d1dea43fcdba0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FKB-4.png" + }, + "html": { + "href": "https://bitbucket.org/%7B600000000-0000-0000-0000-000000000000%7D/" + } + }, + "type": "user", + "uuid": "{600000000-0000-0000-0000-000000000000}", + "account_id": "00000000-0000-0000-0000-000000000000", + "nickname": "bb bot" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931901" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931901" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 49893111, + "created_on": "2024-05-07T12:22:05.981415+00:00", + "updated_on": "2024-05-07T12:22:05.981490+00:00", + "content": { + "type": "rendered", + "raw": "Ran Apply for 0 projects:", + "markup": "markdown", + "html": "

Ran Apply for 0 projects:

" + }, + "user": { + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B600000000-0000-0000-0000-000000000000%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/d5c4bac76953df92f47d1dea43fcdba0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FKB-4.png" + }, + "html": { + "href": "https://bitbucket.org/%7B600000000-0000-0000-0000-000000000000%7D/" + } + }, + "type": "user", + "uuid": "{600000000-0000-0000-0000-000000000000}", + "account_id": "00000000-0000-0000-0000-000000000000", + "nickname": "bb bot" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931901" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931901" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + } + ], + "pagelen": 10, + "size": 4, + "page": 1 +} diff --git a/server/events/vcs/bitbucketcloud/testdata/user.json b/server/events/vcs/bitbucketcloud/testdata/user.json new file mode 100644 index 0000000000..336f27832a --- /dev/null +++ b/server/events/vcs/bitbucketcloud/testdata/user.json @@ -0,0 +1,33 @@ +{ + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "avatar": { + "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/RR-3.png" + }, + "repositories": { + "href": "https://api.bitbucket.org/2.0/repositories/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "snippets": { + "href": "https://api.bitbucket.org/2.0/snippets/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "html": { + "href": "https://bitbucket.org/%7B00000000-0000-0000-0000-000000000001%7D/" + }, + "hooks": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7B00000000-0000-0000-0000-000000000001%7D/hooks" + } + }, + "created_on": "2024-02-01T12:08:46.355300+00:00", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}", + "has_2fa_enabled": null, + "username": "bb-bot", + "is_staff": false, + "account_id": "000000:00000000-0000-0000-0000-000000000001", + "nickname": "bb bot", + "account_status": "active", + "location": null +} diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go index 058b411100..47f61a526a 100644 --- a/server/events/vcs/bitbucketserver/client.go +++ b/server/events/vcs/bitbucketserver/client.go @@ -350,7 +350,7 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b } // GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). -func (b *Client) GetTeamNamesForUser(_ models.Repo, _ models.User) ([]string, error) { +func (b *Client) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { return nil, nil } diff --git a/server/events/vcs/client.go b/server/events/vcs/client.go index 9e32981a82..3344ffa6e9 100644 --- a/server/events/vcs/client.go +++ b/server/events/vcs/client.go @@ -42,7 +42,7 @@ type Client interface { DiscardReviews(repo models.Repo, pull models.PullRequest) error MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error MarkdownPullLink(pull models.PullRequest) (string, error) - GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) + GetTeamNamesForUser(logger logging.SimpleLogging, repo models.Repo, user models.User) ([]string, error) // GetFileContent a repository file content from VCS (which support fetch a single file from repository) // The first return value indicates whether the repo contains a file or not diff --git a/server/events/vcs/gitea/client.go b/server/events/vcs/gitea/client.go index e971534288..e262d8d820 100644 --- a/server/events/vcs/gitea/client.go +++ b/server/events/vcs/gitea/client.go @@ -413,7 +413,7 @@ func (c *GiteaClient) MarkdownPullLink(pull models.PullRequest) (string, error) } // GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). -func (c *GiteaClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { +func (c *GiteaClient) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { // TODO: implement return nil, errors.New("GetTeamNamesForUser not (yet) implemented for Gitea client") } diff --git a/server/events/vcs/github_client.go b/server/events/vcs/github_client.go index 1000f73e07..89a79bf98d 100644 --- a/server/events/vcs/github_client.go +++ b/server/events/vcs/github_client.go @@ -25,7 +25,8 @@ import ( "strings" "time" - "github.com/google/go-github/v66/github" + "github.com/gofri/go-github-ratelimit/github_ratelimit" + "github.com/google/go-github/v68/github" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" @@ -124,15 +125,24 @@ func NewGithubClient(hostname string, credentials GithubCredentials, config Gith return nil, errors.Wrap(err, "error initializing github authentication transport") } + transportWithRateLimit, err := github_ratelimit.NewRateLimitWaiterClient( + transport.Transport, + github_ratelimit.WithTotalSleepLimit(time.Minute, func(callbackContext *github_ratelimit.CallbackContext) { + logger.Warn("github rate limit exceeded total sleep time, requests will fail to avoid penalties from github") + })) + if err != nil { + return nil, errors.Wrap(err, "error initializing github rate limit transport") + } + var graphqlURL string var client *github.Client if hostname == "github.com" { - client = github.NewClient(transport) + client = github.NewClient(transportWithRateLimit) graphqlURL = "https://api.github.com/graphql" } else { apiURL := resolveGithubAPIURL(hostname) // TODO: Deprecated: Use NewClient(httpClient).WithEnterpriseURLs(baseURL, uploadURL) instead - client, err = github.NewEnterpriseClient(apiURL.String(), apiURL.String(), transport) //nolint:staticcheck + client, err = github.NewEnterpriseClient(apiURL.String(), apiURL.String(), transportWithRateLimit) //nolint:staticcheck if err != nil { return nil, err } @@ -140,7 +150,7 @@ func NewGithubClient(hostname string, credentials GithubCredentials, config Gith } // Use the client from shurcooL's githubv4 library for queries. - v4Client := githubv4.NewEnterpriseClient(graphqlURL, transport) + v4Client := githubv4.NewEnterpriseClient(graphqlURL, transportWithRateLimit) user, err := credentials.GetUser() logger.Debug("GH User: %s", user) @@ -268,8 +278,8 @@ func (g *GithubClient) HidePrevCommandComments(logger logging.SimpleLogging, rep nextPage := 0 for { comments, resp, err := g.client.Issues.ListComments(g.ctx, repo.Owner, repo.Name, pullNum, &github.IssueListCommentsOptions{ - Sort: github.String("created"), - Direction: github.String("asc"), + Sort: github.Ptr("created"), + Direction: github.Ptr("asc"), ListOptions: github.ListOptions{Page: nextPage}, }) if resp != nil { @@ -913,9 +923,9 @@ func (g *GithubClient) UpdateStatus(logger logging.SimpleLogging, repo models.Re logger.Info("Updating GitHub Check status for '%s' to '%s'", src, ghState) status := &github.RepoStatus{ - State: github.String(ghState), - Description: github.String(description), - Context: github.String(src), + State: github.Ptr(ghState), + Description: github.Ptr(description), + Context: github.Ptr(src), TargetURL: &url, } _, resp, err := g.client.Repositories.CreateStatus(g.ctx, repo.Owner, repo.Name, pull.HeadCommit, status) @@ -1009,7 +1019,8 @@ func (g *GithubClient) MarkdownPullLink(pull models.PullRequest) (string, error) // GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). // https://docs.github.com/en/graphql/reference/objects#organization -func (g *GithubClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { +func (g *GithubClient) GetTeamNamesForUser(logger logging.SimpleLogging, repo models.Repo, user models.User) ([]string, error) { + logger.Debug("Getting GitHub team names for user '%s'", user) orgName := repo.Owner variables := map[string]interface{}{ "orgName": githubv4.String(orgName), diff --git a/server/events/vcs/github_client_test.go b/server/events/vcs/github_client_test.go index 8d4912616d..6d54df4c59 100644 --- a/server/events/vcs/github_client_test.go +++ b/server/events/vcs/github_client_test.go @@ -11,6 +11,7 @@ import ( "os" "strings" "testing" + "time" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" @@ -1397,11 +1398,13 @@ func TestGithubClient_GetTeamNamesForUser(t *testing.T) { Ok(t, err) defer disableSSLVerification()() - teams, err := client.GetTeamNamesForUser(models.Repo{ - Owner: "testrepo", - }, models.User{ - Username: "testuser", - }) + teams, err := client.GetTeamNamesForUser( + logger, + models.Repo{ + Owner: "testrepo", + }, models.User{ + Username: "testuser", + }) Ok(t, err) Equals(t, []string{"Frontend Developers", "frontend-developers", "Employees", "employees"}, teams) } @@ -1713,3 +1716,54 @@ func TestGithubClient_GetPullLabels_EmptyResponse(t *testing.T) { Ok(t, err) Equals(t, 0, len(labels)) } + +func TestGithubClient_SecondaryRateLimitHandling_CreateComment(t *testing.T) { + logger := logging.NewNoopLogger(t) + calls := 0 + maxCalls := 2 + + testServer := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v3/repos/owner/repo/issues/1/comments" { + t.Errorf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + + if calls < maxCalls { + // Secondary rate limiting, x-ratelimit-remaining must be > 0 + w.Header().Set("x-ratelimit-remaining", "1") + w.Header().Set("x-ratelimit-reset", fmt.Sprintf("%d", time.Now().Unix()+1)) + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message": "You have exceeded a secondary rate limit"}`)) // nolint: errcheck + } else { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id": 1, "body": "Test comment"}`)) // nolint: errcheck + } + calls++ + }), + ) + + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + + client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{User: "user", Token: "pass"}, vcs.GithubConfig{}, 0, logger) + Ok(t, err) + defer disableSSLVerification()() + + // Simulate creating a comment + repo := models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + } + pullNum := 1 + comment := "Test comment" + + err = client.CreateComment(logger, repo, pullNum, comment, "") + Ok(t, err) + + // Verify that the number of calls is greater than maxCalls, indicating that retries occurred + Assert(t, calls > maxCalls, "Expected more than %d calls due to rate limiting, but got %d", maxCalls, calls) + +} diff --git a/server/events/vcs/github_credentials.go b/server/events/vcs/github_credentials.go index e46b0e3c2c..ad6fadda61 100644 --- a/server/events/vcs/github_credentials.go +++ b/server/events/vcs/github_credentials.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/bradleyfalzon/ghinstallation/v2" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/pkg/errors" ) diff --git a/server/events/vcs/gitlab_client.go b/server/events/vcs/gitlab_client.go index fffe8c63e9..2ac61a403f 100644 --- a/server/events/vcs/gitlab_client.go +++ b/server/events/vcs/gitlab_client.go @@ -29,7 +29,7 @@ import ( "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" - "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" ) // gitlabMaxCommentLength is the maximum number of chars allowed by Gitlab in a @@ -41,6 +41,8 @@ type GitlabClient struct { Client *gitlab.Client // Version is set to the server version. Version *version.Version + // All GitLab groups configured in allowlists and policies + ConfiguredGroups []string // PollingInterval is the time between successive polls, where applicable. PollingInterval time.Duration // PollingInterval is the total duration for which to poll, where applicable. @@ -56,11 +58,12 @@ var commonMarkSupported = MustConstraint(">=11.1") var gitlabClientUnderTest = false // NewGitlabClient returns a valid GitLab client. -func NewGitlabClient(hostname string, token string, logger logging.SimpleLogging) (*GitlabClient, error) { +func NewGitlabClient(hostname string, token string, configuredGroups []string, logger logging.SimpleLogging) (*GitlabClient, error) { logger.Debug("Creating new GitLab client for %s", hostname) client := &GitlabClient{ - PollingInterval: time.Second, - PollingTimeout: time.Second * 30, + ConfiguredGroups: configuredGroups, + PollingInterval: time.Second, + PollingTimeout: time.Second * 30, } // Create the client differently depending on the base URL. @@ -620,9 +623,39 @@ func MustConstraint(constraint string) version.Constraints { return c } -// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). -func (g *GitlabClient) GetTeamNamesForUser(_ models.Repo, _ models.User) ([]string, error) { - return nil, nil +// GetTeamNamesForUser returns the names of the GitLab groups that the user belongs to. +// The user membership is checked in each group from configuredTeams, groups +// that the Atlantis user doesn't have access to are silently ignored. +func (g *GitlabClient) GetTeamNamesForUser(logger logging.SimpleLogging, _ models.Repo, user models.User) ([]string, error) { + logger.Debug("Getting GitLab group names for user '%s'", user) + var teamNames []string + + users, resp, err := g.Client.Users.ListUsers(&gitlab.ListUsersOptions{Username: &user.Username}) + if resp.StatusCode == http.StatusNotFound { + return teamNames, nil + } + if err != nil { + return nil, errors.Wrapf(err, "GET /users returned: %d", resp.StatusCode) + } else if len(users) == 0 { + return nil, errors.Wrap(err, "GET /users returned no user") + } else if len(users) > 1 { + // Theoretically impossible, just being extra safe + return nil, errors.Wrap(err, "GET /users returned more than 1 user") + } + userID := users[0].ID + for _, groupName := range g.ConfiguredGroups { + membership, resp, err := g.Client.GroupMembers.GetGroupMember(groupName, userID) + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden { + continue + } + if err != nil { + return nil, errors.Wrapf(err, "GET /groups/%s/members/%d returned: %d", groupName, userID, resp.StatusCode) + } + if resp.StatusCode == http.StatusOK && membership.State == "active" { + teamNames = append(teamNames, groupName) + } + } + return teamNames, nil } // GetFileContent a repository file content from VCS (which support fetch a single file from repository) diff --git a/server/events/vcs/gitlab_client_test.go b/server/events/vcs/gitlab_client_test.go index 8aee1e865a..49c9a0f8f0 100644 --- a/server/events/vcs/gitlab_client_test.go +++ b/server/events/vcs/gitlab_client_test.go @@ -15,7 +15,7 @@ import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" - "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" . "github.com/runatlantis/atlantis/testing" ) @@ -94,7 +94,7 @@ func TestNewGitlabClient_BaseURL(t *testing.T) { for _, c := range cases { t.Run(c.Hostname, func(t *testing.T) { log := logging.NewNoopLogger(t) - client, err := NewGitlabClient(c.Hostname, "token", log) + client, err := NewGitlabClient(c.Hostname, "token", []string{}, log) Ok(t, err) Equals(t, c.ExpBaseURL, client.Client.BaseURL().String()) }) @@ -887,7 +887,7 @@ func TestGitlabClient_MarkdownPullLink(t *testing.T) { logger := logging.NewNoopLogger(t) gitlabClientUnderTest = true defer func() { gitlabClientUnderTest = false }() - client, err := NewGitlabClient("gitlab.com", "token", logger) + client, err := NewGitlabClient("gitlab.com", "token", []string{}, logger) Ok(t, err) pull := models.PullRequest{Num: 1} s, _ := client.MarkdownPullLink(pull) @@ -1039,7 +1039,7 @@ func TestGitlabClient_HideOldComments(t *testing.T) { } } -func TestGithubClient_GetPullLabels(t *testing.T) { +func TestGitlabClient_GetPullLabels(t *testing.T) { logger := logging.NewNoopLogger(t) mergeSuccessWithLabel, err := os.ReadFile("testdata/gitlab-merge-success-with-label.json") Ok(t, err) @@ -1076,7 +1076,7 @@ func TestGithubClient_GetPullLabels(t *testing.T) { Equals(t, []string{"work in progress"}, labels) } -func TestGithubClient_GetPullLabels_EmptyResponse(t *testing.T) { +func TestGitlabClient_GetPullLabels_EmptyResponse(t *testing.T) { logger := logging.NewNoopLogger(t) pipelineSuccess, err := os.ReadFile("testdata/gitlab-pipeline-success.json") Ok(t, err) @@ -1110,3 +1110,51 @@ func TestGithubClient_GetPullLabels_EmptyResponse(t *testing.T) { Ok(t, err) Equals(t, 0, len(labels)) } + +// GetTeamNamesForUser returns the names of the GitLab groups that the user belongs to. +func TestGitlabClient_GetTeamNamesForUser(t *testing.T) { + logger := logging.NewNoopLogger(t) + + groupMembershipSuccess, err := os.ReadFile("testdata/gitlab-group-membership-success.json") + Ok(t, err) + + userSuccess, err := os.ReadFile("testdata/gitlab-user-success.json") + Ok(t, err) + + configuredGroups := []string{"someorg/group1", "someorg/group2", "someorg/group3", "someorg/group4"} + testServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/api/v4/users?username=testuser": + w.WriteHeader(http.StatusOK) + w.Write(userSuccess) // nolint: errcheck + case "/api/v4/groups/someorg%2Fgroup1/members/123", "/api/v4/groups/someorg%2Fgroup2/members/123": + w.WriteHeader(http.StatusOK) + w.Write(groupMembershipSuccess) // nolint: errcheck + case "/api/v4/groups/someorg%2Fgroup3/members/123": + http.Error(w, "forbidden", http.StatusForbidden) + case "/api/v4/groups/someorg%2Fgroup4/members/123": + http.Error(w, "not found", http.StatusNotFound) + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + } + })) + internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) + Ok(t, err) + client := &GitlabClient{ + Client: internalClient, + Version: nil, + ConfiguredGroups: configuredGroups, + } + + teams, err := client.GetTeamNamesForUser( + logger, + models.Repo{ + Owner: "someorg", + }, models.User{ + Username: "testuser", + }) + Ok(t, err) + Equals(t, []string{"someorg/group1", "someorg/group2"}, teams) +} diff --git a/server/events/vcs/instrumented_client.go b/server/events/vcs/instrumented_client.go index adc0ca0abc..d5d5809d9c 100644 --- a/server/events/vcs/instrumented_client.go +++ b/server/events/vcs/instrumented_client.go @@ -3,7 +3,7 @@ package vcs import ( "strconv" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" diff --git a/server/events/vcs/mocks/mock_client.go b/server/events/vcs/mocks/mock_client.go index e798b8b79a..3b4bd7dbf7 100644 --- a/server/events/vcs/mocks/mock_client.go +++ b/server/events/vcs/mocks/mock_client.go @@ -136,11 +136,11 @@ func (mock *MockClient) GetPullLabels(logger logging.SimpleLogging, repo models. return _ret0, _ret1 } -func (mock *MockClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { +func (mock *MockClient) GetTeamNamesForUser(logger logging.SimpleLogging, repo models.Repo, user models.User) ([]string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } - _params := []pegomock.Param{repo, user} + _params := []pegomock.Param{logger, repo, user} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetTeamNamesForUser", _params, []reflect.Type{reflect.TypeOf((*[]string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []string var _ret1 error @@ -576,8 +576,8 @@ func (c *MockClient_GetPullLabels_OngoingVerification) GetAllCapturedArguments() return } -func (verifier *VerifierMockClient) GetTeamNamesForUser(repo models.Repo, user models.User) *MockClient_GetTeamNamesForUser_OngoingVerification { - _params := []pegomock.Param{repo, user} +func (verifier *VerifierMockClient) GetTeamNamesForUser(logger logging.SimpleLogging, repo models.Repo, user models.User) *MockClient_GetTeamNamesForUser_OngoingVerification { + _params := []pegomock.Param{logger, repo, user} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetTeamNamesForUser", _params, verifier.timeout) return &MockClient_GetTeamNamesForUser_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } @@ -587,24 +587,30 @@ type MockClient_GetTeamNamesForUser_OngoingVerification struct { methodInvocations []pegomock.MethodInvocation } -func (c *MockClient_GetTeamNamesForUser_OngoingVerification) GetCapturedArguments() (models.Repo, models.User) { - repo, user := c.GetAllCapturedArguments() - return repo[len(repo)-1], user[len(user)-1] +func (c *MockClient_GetTeamNamesForUser_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.User) { + logger, repo, user := c.GetAllCapturedArguments() + return logger[len(logger)-1], repo[len(repo)-1], user[len(user)-1] } -func (c *MockClient_GetTeamNamesForUser_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.User) { +func (c *MockClient_GetTeamNamesForUser_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.User) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { - _param0 = make([]models.Repo, len(c.methodInvocations)) + _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { - _param0[u] = param.(models.Repo) + _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { - _param1 = make([]models.User, len(c.methodInvocations)) + _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { - _param1[u] = param.(models.User) + _param1[u] = param.(models.Repo) + } + } + if len(_params) > 2 { + _param2 = make([]models.User, len(c.methodInvocations)) + for u, param := range _params[2] { + _param2[u] = param.(models.User) } } } diff --git a/server/events/vcs/mocks/mock_github_pull_request_getter.go b/server/events/vcs/mocks/mock_github_pull_request_getter.go index f8a809e44f..ad5670ca14 100644 --- a/server/events/vcs/mocks/mock_github_pull_request_getter.go +++ b/server/events/vcs/mocks/mock_github_pull_request_getter.go @@ -4,7 +4,7 @@ package mocks import ( - github "github.com/google/go-github/v66/github" + github "github.com/google/go-github/v68/github" pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" diff --git a/server/events/vcs/not_configured_vcs_client.go b/server/events/vcs/not_configured_vcs_client.go index 41b14ad2c6..0a48f210b6 100644 --- a/server/events/vcs/not_configured_vcs_client.go +++ b/server/events/vcs/not_configured_vcs_client.go @@ -60,7 +60,7 @@ func (a *NotConfiguredVCSClient) MarkdownPullLink(_ models.PullRequest) (string, func (a *NotConfiguredVCSClient) err() error { return fmt.Errorf("atlantis was not configured to support repos from %s", a.Host.String()) } -func (a *NotConfiguredVCSClient) GetTeamNamesForUser(_ models.Repo, _ models.User) ([]string, error) { +func (a *NotConfiguredVCSClient) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { return nil, a.err() } diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index 68aa45bf58..71d66116b1 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -97,8 +97,8 @@ func (d *ClientProxy) MarkdownPullLink(pull models.PullRequest) (string, error) return d.clients[pull.BaseRepo.VCSHost.Type].MarkdownPullLink(pull) } -func (d *ClientProxy) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { - return d.clients[repo.VCSHost.Type].GetTeamNamesForUser(repo, user) +func (d *ClientProxy) GetTeamNamesForUser(logger logging.SimpleLogging, repo models.Repo, user models.User) ([]string, error) { + return d.clients[repo.VCSHost.Type].GetTeamNamesForUser(logger, repo, user) } func (d *ClientProxy) GetFileContent(logger logging.SimpleLogging, pull models.PullRequest, fileName string) (bool, []byte, error) { diff --git a/server/events/vcs/testdata/fixtures.go b/server/events/vcs/testdata/fixtures.go index db17101876..aa18059bac 100644 --- a/server/events/vcs/testdata/fixtures.go +++ b/server/events/vcs/testdata/fixtures.go @@ -22,43 +22,43 @@ import ( "testing" "github.com/golang-jwt/jwt/v5" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/mcdafydd/go-azuredevops/azuredevops" ) var PullEvent = github.PullRequestEvent{ Sender: &github.User{ - Login: github.String("user"), + Login: github.Ptr("user"), }, Repo: &Repo, PullRequest: &Pull, - Action: github.String("opened"), + Action: github.Ptr("opened"), } var Pull = github.PullRequest{ Head: &github.PullRequestBranch{ - SHA: github.String("sha256"), - Ref: github.String("ref"), + SHA: github.Ptr("sha256"), + Ref: github.Ptr("ref"), Repo: &Repo, }, Base: &github.PullRequestBranch{ - SHA: github.String("sha256"), + SHA: github.Ptr("sha256"), Repo: &Repo, - Ref: github.String("basebranch"), + Ref: github.Ptr("basebranch"), }, - HTMLURL: github.String("html-url"), + HTMLURL: github.Ptr("html-url"), User: &github.User{ - Login: github.String("user"), + Login: github.Ptr("user"), }, - Number: github.Int(1), - State: github.String("open"), + Number: github.Ptr(1), + State: github.Ptr("open"), } var Repo = github.Repository{ - FullName: github.String("owner/repo"), - Owner: &github.User{Login: github.String("owner")}, - Name: github.String("repo"), - CloneURL: github.String("https://github.com/owner/repo.git"), + FullName: github.Ptr("owner/repo"), + Owner: &github.User{Login: github.Ptr("owner")}, + Name: github.Ptr("repo"), + CloneURL: github.Ptr("https://github.com/owner/repo.git"), } var ADPullEvent = azuredevops.Event{ diff --git a/server/events/vcs/testdata/gitlab-group-membership-success.json b/server/events/vcs/testdata/gitlab-group-membership-success.json new file mode 100644 index 0000000000..897c4438ad --- /dev/null +++ b/server/events/vcs/testdata/gitlab-group-membership-success.json @@ -0,0 +1,22 @@ +{ + "access_level": 50, + "created_at": "2023-11-28T01:23:45.789Z", + "created_by": { + "id": 456, + "username": "someone", + "name": "Someone", + "state": "active", + "locked": false, + "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/456/avatar.png", + "web_url": "https://gitlab.com/someone" + }, + "expires_at": null, + "id": 123, + "username": "testuser", + "name": "Test User", + "state": "active", + "locked": false, + "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/123/avatar.png", + "web_url": "https://gitlab.com/testuser", + "membership_state": "active" +} diff --git a/server/events/vcs/testdata/gitlab-user-success.json b/server/events/vcs/testdata/gitlab-user-success.json new file mode 100644 index 0000000000..0b87fc9e12 --- /dev/null +++ b/server/events/vcs/testdata/gitlab-user-success.json @@ -0,0 +1,11 @@ +[ + { + "id": 123, + "username": "testuser", + "name": "Test User", + "state": "active", + "locked": false, + "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/123/avatar.png", + "web_url": "https://gitlab.com/testuser" + } +] diff --git a/server/events/webhooks/http.go b/server/events/webhooks/http.go new file mode 100644 index 0000000000..6f540ac154 --- /dev/null +++ b/server/events/webhooks/http.go @@ -0,0 +1,65 @@ +package webhooks + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/logging" +) + +// HttpWebhook sends webhooks to any HTTP destination. +type HttpWebhook struct { + Client *HttpClient + WorkspaceRegex *regexp.Regexp + BranchRegex *regexp.Regexp + URL string +} + +// Send sends the webhook to URL if workspace and branch matches their respective regex. +func (h *HttpWebhook) Send(_ logging.SimpleLogging, applyResult ApplyResult) error { + if !h.WorkspaceRegex.MatchString(applyResult.Workspace) || !h.BranchRegex.MatchString(applyResult.Pull.BaseBranch) { + return nil + } + if err := h.doSend(applyResult); err != nil { + return errors.Wrap(err, fmt.Sprintf("sending webhook to %q", h.URL)) + } + return nil +} + +func (h *HttpWebhook) doSend(applyResult ApplyResult) error { + body, err := json.Marshal(applyResult) + if err != nil { + return err + } + req, err := http.NewRequest("POST", h.URL, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for header, values := range h.Client.Headers { + for _, value := range values { + req.Header.Add(header, value) + } + } + resp, err := h.Client.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("returned status code %d with response %q", resp.StatusCode, respBody) + } + return nil +} + +// HttpClient wraps http.Client allowing to add arbitrary Headers to a request. +type HttpClient struct { + Client *http.Client + Headers map[string][]string +} diff --git a/server/events/webhooks/http_test.go b/server/events/webhooks/http_test.go new file mode 100644 index 0000000000..66862cf054 --- /dev/null +++ b/server/events/webhooks/http_test.go @@ -0,0 +1,127 @@ +package webhooks_test + +import ( + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/webhooks" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +var httpApplyResult = webhooks.ApplyResult{ + Workspace: "production", + Repo: models.Repo{ + FullName: "runatlantis/atlantis", + }, + Pull: models.PullRequest{ + Num: 1, + URL: "url", + BaseBranch: "main", + }, + User: models.User{ + Username: "lkysow", + }, + Success: true, +} + +func TestHttpWebhookWithHeaders(t *testing.T) { + expectedHeaders := map[string][]string{ + "Authorization": {"Bearer token"}, + "X-Custom-Header": {"value1", "value2"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Equals(t, r.Header.Get("Content-Type"), "application/json") + for k, v := range expectedHeaders { + Equals(t, r.Header.Values(k), v) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + webhook := webhooks.HttpWebhook{ + Client: &webhooks.HttpClient{Client: http.DefaultClient, Headers: expectedHeaders}, + URL: server.URL, + WorkspaceRegex: regexp.MustCompile(".*"), + BranchRegex: regexp.MustCompile(".*"), + } + + err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult) + Ok(t, err) +} + +func TestHttpWebhookNoHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Equals(t, r.Header.Get("Content-Type"), "application/json") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + webhook := webhooks.HttpWebhook{ + Client: &webhooks.HttpClient{Client: http.DefaultClient}, + URL: server.URL, + WorkspaceRegex: regexp.MustCompile(".*"), + BranchRegex: regexp.MustCompile(".*"), + } + + err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult) + Ok(t, err) +} + +func TestHttpWebhook500(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + webhook := webhooks.HttpWebhook{ + Client: &webhooks.HttpClient{Client: http.DefaultClient}, + URL: server.URL, + WorkspaceRegex: regexp.MustCompile(".*"), + BranchRegex: regexp.MustCompile(".*"), + } + + err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult) + ErrContains(t, "sending webhook", err) +} + +func TestHttpNoRegexMatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Assert(t, false, "webhook should not be sent") + })) + defer server.Close() + + tt := []struct { + name string + wr *regexp.Regexp + br *regexp.Regexp + }{ + { + name: "no workspace match", + wr: regexp.MustCompile("other"), + br: regexp.MustCompile(".*"), + }, + { + name: "no branch match", + wr: regexp.MustCompile(".*"), + br: regexp.MustCompile("other"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + webhook := webhooks.HttpWebhook{ + Client: &webhooks.HttpClient{Client: http.DefaultClient}, + URL: server.URL, + WorkspaceRegex: tc.wr, + BranchRegex: tc.br, + } + err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult) + Ok(t, err) + }) + } +} diff --git a/server/events/webhooks/webhooks.go b/server/events/webhooks/webhooks.go index c4b43239a7..09d74371ed 100644 --- a/server/events/webhooks/webhooks.go +++ b/server/events/webhooks/webhooks.go @@ -24,6 +24,7 @@ import ( ) const SlackKind = "slack" +const HttpKind = "http" const ApplyEvent = "apply" //go:generate pegomock generate --package mocks -o mocks/mock_sender.go Sender @@ -36,12 +37,13 @@ type Sender interface { // ApplyResult is the result of a terraform apply. type ApplyResult struct { - Workspace string - Repo models.Repo - Pull models.PullRequest - User models.User - Success bool - Directory string + Workspace string + Repo models.Repo + Pull models.PullRequest + User models.User + Success bool + Directory string + ProjectName string } // MultiWebhookSender sends multiple webhooks for each one it's configured for. @@ -55,9 +57,15 @@ type Config struct { BranchRegex string Kind string Channel string + URL string } -func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookSender, error) { +type Clients struct { + Slack SlackClient + Http *HttpClient +} + +func NewMultiWebhookSender(configs []Config, clients Clients) (*MultiWebhookSender, error) { var webhooks []Sender for _, c := range configs { wr, err := regexp.Compile(c.WorkspaceRegex) @@ -76,19 +84,30 @@ func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookS } switch c.Kind { case SlackKind: - if !client.TokenIsSet() { + if !clients.Slack.TokenIsSet() { return nil, errors.New("must specify top-level \"slack-token\" if using a webhook of \"kind: slack\"") } if c.Channel == "" { return nil, errors.New("must specify \"channel\" if using a webhook of \"kind: slack\"") } - slack, err := NewSlack(wr, br, c.Channel, client) + slack, err := NewSlack(wr, br, c.Channel, clients.Slack) if err != nil { return nil, err } webhooks = append(webhooks, slack) + case HttpKind: + if c.URL == "" { + return nil, errors.New("must specify \"url\" if using a webhook of \"kind: http\"") + } + httpWebhook := &HttpWebhook{ + Client: clients.Http, + WorkspaceRegex: wr, + BranchRegex: br, + URL: c.URL, + } + webhooks = append(webhooks, httpWebhook) default: - return nil, fmt.Errorf("\"kind: %s\" not supported. Only \"kind: %s\" is supported right now", c.Kind, SlackKind) + return nil, fmt.Errorf("\"kind: %s\" not supported. Only \"kind: %s\" and \"kind: %s\" are supported right now", c.Kind, SlackKind, HttpKind) } } @@ -101,7 +120,7 @@ func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookS func (w *MultiWebhookSender) Send(log logging.SimpleLogging, result ApplyResult) error { for _, w := range w.Webhooks { if err := w.Send(log, result); err != nil { - log.Warn("error sending slack webhook: %s", err) + log.Warn("error sending webhook: %s", err) } } return nil diff --git a/server/events/webhooks/webhooks_test.go b/server/events/webhooks/webhooks_test.go index 5ee00bf599..edcd80c025 100644 --- a/server/events/webhooks/webhooks_test.go +++ b/server/events/webhooks/webhooks_test.go @@ -14,6 +14,7 @@ package webhooks_test import ( + "net/http" "strings" "testing" @@ -43,15 +44,22 @@ func validConfigs() []webhooks.Config { return []webhooks.Config{validConfig} } +func validClients() webhooks.Clients { + return webhooks.Clients{ + Slack: mocks.NewMockSlackClient(), + Http: &webhooks.HttpClient{Client: http.DefaultClient}, + } +} + func TestNewWebhooksManager_InvalidWorkspaceRegex(t *testing.T) { t.Log("When given an invalid workspace regex in a config, an error is returned") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() + clients := validClients() invalidRegex := "(" configs := validConfigs() configs[0].WorkspaceRegex = invalidRegex - _, err := webhooks.NewMultiWebhookSender(configs, client) + _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error") } @@ -59,12 +67,12 @@ func TestNewWebhooksManager_InvalidWorkspaceRegex(t *testing.T) { func TestNewWebhooksManager_InvalidBranchRegex(t *testing.T) { t.Log("When given an invalid branch regex in a config, an error is returned") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() + clients := validClients() invalidRegex := "(" configs := validConfigs() configs[0].BranchRegex = invalidRegex - _, err := webhooks.NewMultiWebhookSender(configs, client) + _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error") } @@ -72,13 +80,13 @@ func TestNewWebhooksManager_InvalidBranchRegex(t *testing.T) { func TestNewWebhooksManager_InvalidBranchAndWorkspaceRegex(t *testing.T) { t.Log("When given an invalid branch and invalid workspace regex in a config, an error is returned") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() + clients := validClients() invalidRegex := "(" configs := validConfigs() configs[0].WorkspaceRegex = invalidRegex configs[0].BranchRegex = invalidRegex - _, err := webhooks.NewMultiWebhookSender(configs, client) + _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error") } @@ -86,10 +94,10 @@ func TestNewWebhooksManager_InvalidBranchAndWorkspaceRegex(t *testing.T) { func TestNewWebhooksManager_NoEvent(t *testing.T) { t.Log("When the event key is not specified in a config, an error is returned") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() + clients := validClients() configs := validConfigs() configs[0].Event = "" - _, err := webhooks.NewMultiWebhookSender(configs, client) + _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error()) } @@ -97,12 +105,12 @@ func TestNewWebhooksManager_NoEvent(t *testing.T) { func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) { t.Log("When given an unsupported event in a config, an error is returned") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() + clients := validClients() unsupportedEvent := "badevent" configs := validConfigs() configs[0].Event = unsupportedEvent - _, err := webhooks.NewMultiWebhookSender(configs, client) + _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Equals(t, "\"event: badevent\" not supported. Only \"event: apply\" is supported right now", err.Error()) } @@ -110,10 +118,10 @@ func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) { func TestNewWebhooksManager_NoKind(t *testing.T) { t.Log("When the kind key is not specified in a config, an error is returned") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() + clients := validClients() configs := validConfigs() configs[0].Kind = "" - _, err := webhooks.NewMultiWebhookSender(configs, client) + _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error()) } @@ -121,14 +129,14 @@ func TestNewWebhooksManager_NoKind(t *testing.T) { func TestNewWebhooksManager_UnsupportedKind(t *testing.T) { t.Log("When given an unsupported kind in a config, an error is returned") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() + clients := validClients() unsupportedKind := "badkind" configs := validConfigs() configs[0].Kind = unsupportedKind - _, err := webhooks.NewMultiWebhookSender(configs, client) + _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") - Equals(t, "\"kind: badkind\" not supported. Only \"kind: slack\" is supported right now", err.Error()) + Equals(t, "\"kind: badkind\" not supported. Only \"kind: slack\" and \"kind: http\" are supported right now", err.Error()) } func TestNewWebhooksManager_NoConfigSuccess(t *testing.T) { @@ -136,23 +144,27 @@ func TestNewWebhooksManager_NoConfigSuccess(t *testing.T) { t.Log("passing any client should succeed") var emptyConfigs []webhooks.Config emptyToken := "" - m, err := webhooks.NewMultiWebhookSender(emptyConfigs, webhooks.NewSlackClient(emptyToken)) + anyClients := webhooks.Clients{ + Slack: webhooks.NewSlackClient(emptyToken), + Http: &webhooks.HttpClient{Client: http.DefaultClient}, + } + m, err := webhooks.NewMultiWebhookSender(emptyConfigs, anyClients) Ok(t, err) Equals(t, 0, len(m.Webhooks)) // nolint: staticcheck t.Log("passing nil client should succeed") - m, err = webhooks.NewMultiWebhookSender(emptyConfigs, nil) + m, err = webhooks.NewMultiWebhookSender(emptyConfigs, webhooks.Clients{}) Ok(t, err) Equals(t, 0, len(m.Webhooks)) // nolint: staticcheck } func TestNewWebhooksManager_SingleConfigSuccess(t *testing.T) { t.Log("When there is one valid config, function should succeed") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() - When(client.TokenIsSet()).ThenReturn(true) + clients := validClients() + When(clients.Slack.TokenIsSet()).ThenReturn(true) configs := validConfigs() - m, err := webhooks.NewMultiWebhookSender(configs, client) + m, err := webhooks.NewMultiWebhookSender(configs, clients) Ok(t, err) Equals(t, 1, len(m.Webhooks)) // nolint: staticcheck } @@ -160,15 +172,15 @@ func TestNewWebhooksManager_SingleConfigSuccess(t *testing.T) { func TestNewWebhooksManager_MultipleConfigSuccess(t *testing.T) { t.Log("When there are multiple valid configs, function should succeed") RegisterMockTestingT(t) - client := mocks.NewMockSlackClient() - When(client.TokenIsSet()).ThenReturn(true) + clients := validClients() + When(clients.Slack.TokenIsSet()).ThenReturn(true) var configs []webhooks.Config nConfigs := 5 for i := 0; i < nConfigs; i++ { configs = append(configs, validConfig) } - m, err := webhooks.NewMultiWebhookSender(configs, client) + m, err := webhooks.NewMultiWebhookSender(configs, clients) Ok(t, err) Equals(t, nConfigs, len(m.Webhooks)) // nolint: staticcheck } diff --git a/server/server.go b/server/server.go index 22f6db5498..97363fcdf3 100644 --- a/server/server.go +++ b/server/server.go @@ -28,6 +28,7 @@ import ( "os" "os/signal" "path/filepath" + "slices" "sort" "strings" "syscall" @@ -42,6 +43,7 @@ import ( "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/redis" + "github.com/runatlantis/atlantis/server/core/terraform/tfclient" "github.com/runatlantis/atlantis/server/jobs" "github.com/runatlantis/atlantis/server/metrics" "github.com/runatlantis/atlantis/server/scheduled" @@ -127,12 +129,13 @@ type Server struct { // Config holds config for server that isn't passed in by the user. type Config struct { - AllowForkPRsFlag string - AtlantisURLFlag string - AtlantisVersion string - DefaultTFVersionFlag string - RepoConfigJSONFlag string - SilenceForkPRErrorsFlag string + AllowForkPRsFlag string + AtlantisURLFlag string + AtlantisVersion string + DefaultTFDistributionFlag string + DefaultTFVersionFlag string + RepoConfigJSONFlag string + SilenceForkPRErrorsFlag string } // WebhookConfig is nested within UserConfig. It's used to configure webhooks. @@ -147,11 +150,14 @@ type WebhookConfig struct { // that is being modified for this event. If the regex matches, we'll // send the webhook, ex. "main.*". BranchRegex string `mapstructure:"branch-regex"` - // Kind is the type of webhook we should send, ex. slack. + // Kind is the type of webhook we should send, ex. slack or http. Kind string `mapstructure:"kind"` // Channel is the channel to send this webhook to. It only applies to // slack webhooks. Should be without '#'. Channel string `mapstructure:"channel"` + // URL is the URL where to deliver this webhook. It only applies to + // http webhooks. + URL string `mapstructure:"url"` } //go:embed static @@ -269,7 +275,15 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if userConfig.GitlabUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.Gitlab) var err error - gitlabClient, err = vcs.NewGitlabClient(userConfig.GitlabHostname, userConfig.GitlabToken, logger) + + gitlabGroupAllowlistChecker, err := command.NewTeamAllowlistChecker(userConfig.GitlabGroupAllowlist) + if err != nil { + return nil, err + } + + gitlabGroups := slices.Concat(gitlabGroupAllowlistChecker.AllTeams(), globalCfg.PolicySets.AllTeams()) + slices.Sort(gitlabGroups) + gitlabClient, err = vcs.NewGitlabClient(userConfig.GitlabHostname, userConfig.GitlabToken, slices.Compact(gitlabGroups), logger) if err != nil { return nil, err } @@ -377,10 +391,21 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { Event: c.Event, Kind: c.Kind, WorkspaceRegex: c.WorkspaceRegex, + URL: c.URL, } webhooksConfig = append(webhooksConfig, config) } - webhooksManager, err := webhooks.NewMultiWebhookSender(webhooksConfig, webhooks.NewSlackClient(userConfig.SlackToken)) + webhookHeaders, err := userConfig.ToWebhookHttpHeaders() + if err != nil { + return nil, errors.Wrap(err, "parsing webhook http headers") + } + webhooksManager, err := webhooks.NewMultiWebhookSender( + webhooksConfig, + webhooks.Clients{ + Slack: webhooks.NewSlackClient(userConfig.SlackToken), + Http: &webhooks.HttpClient{Client: http.DefaultClient, Headers: webhookHeaders}, + }, + ) if err != nil { return nil, errors.Wrap(err, "initializing webhooks") } @@ -427,12 +452,9 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { ) } - distribution := terraform.NewDistributionTerraform() - if userConfig.TFDistribution == "opentofu" { - distribution = terraform.NewDistributionOpenTofu() - } + distribution := terraform.NewDistribution(userConfig.DefaultTFDistribution) - terraformClient, err := terraform.NewClient( + terraformClient, err := tfclient.NewClient( logger, distribution, binDir, @@ -449,7 +471,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { // are, then we don't error out because we don't have/want terraform // installed on our CI system where the unit tests run. if err != nil && flag.Lookup("test.v") == nil { - return nil, errors.Wrap(err, fmt.Sprintf("initializing %s", userConfig.TFDistribution)) + return nil, errors.Wrap(err, fmt.Sprintf("initializing %s", userConfig.DefaultTFDistribution)) } markdownRenderer := events.NewMarkdownRenderer( gitlabClient.SupportsCommonMark(), @@ -461,6 +483,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { userConfig.MarkdownTemplateOverridesDir, userConfig.ExecutableName, userConfig.HideUnchangedPlanComments, + userConfig.QuietPolicyChecks, ) var lockingClient locking.Locker @@ -586,10 +609,12 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { userConfig.ExecutableName, allowCommands, ) + defaultTfDistribution := terraformClient.DefaultDistribution() defaultTfVersion := terraformClient.DefaultVersion() pendingPlanFinder := &events.DefaultPendingPlanFinder{} runStepRunner := &runtime.RunStepRunner{ TerraformExecutor: terraformClient, + DefaultTFDistribution: defaultTfDistribution, DefaultTFVersion: defaultTfVersion, TerraformBinDir: terraformClient.TerraformBinDir(), ProjectCmdOutputHandler: projectCmdOutputHandler, @@ -648,13 +673,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { terraformClient, ) - showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTfVersion) + showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion) if err != nil { return nil, errors.Wrap(err, "initializing show step runner") } policyCheckStepRunner, err := runtime.NewPolicyCheckStepRunner( + defaultTfDistribution, defaultTfVersion, policy.NewConfTestExecutorWorkflow(logger, binDir, &policy.ConfTestGoGetterVersionDownloader{}), ) @@ -671,18 +697,21 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { VcsClient: vcsClient, Locker: projectLocker, LockURLGenerator: router, + Logger: logger, InitStepRunner: &runtime.InitStepRunner{ - TerraformExecutor: terraformClient, - DefaultTFVersion: defaultTfVersion, + TerraformExecutor: terraformClient, + DefaultTFDistribution: defaultTfDistribution, + DefaultTFVersion: defaultTfVersion, }, - PlanStepRunner: runtime.NewPlanStepRunner(terraformClient, defaultTfVersion, commitStatusUpdater, terraformClient), + PlanStepRunner: runtime.NewPlanStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion, commitStatusUpdater, terraformClient), ShowStepRunner: showStepRunner, PolicyCheckStepRunner: policyCheckStepRunner, ApplyStepRunner: &runtime.ApplyStepRunner{ - TerraformExecutor: terraformClient, - DefaultTFVersion: defaultTfVersion, - CommitStatusUpdater: commitStatusUpdater, - AsyncTFExec: terraformClient, + TerraformExecutor: terraformClient, + DefaultTFDistribution: defaultTfDistribution, + DefaultTFVersion: defaultTfVersion, + CommitStatusUpdater: commitStatusUpdater, + AsyncTFExec: terraformClient, }, RunStepRunner: runStepRunner, EnvStepRunner: &runtime.EnvStepRunner{ @@ -695,8 +724,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { TerraformExecutor: terraformClient, DefaultTFVersion: defaultTfVersion, }, - ImportStepRunner: runtime.NewImportStepRunner(terraformClient, defaultTfVersion), - StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTfVersion), + ImportStepRunner: runtime.NewImportStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion), + StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion), WorkingDir: workingDir, Webhooks: webhooksManager, WorkingDirLocker: workingDirLocker, @@ -834,6 +863,11 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { ExtraArgs: globalCfg.TeamAuthz.Args, ExternalTeamAllowlistRunner: &runtime.DefaultExternalTeamAllowlistRunner{}, } + } else if userConfig.GitlabUser != "" { + teamAllowlistChecker, err = command.NewTeamAllowlistChecker(userConfig.GitlabGroupAllowlist) + if err != nil { + return nil, err + } } else { teamAllowlistChecker, err = command.NewTeamAllowlistChecker(userConfig.GithubTeamAllowlist) if err != nil { diff --git a/server/user_config.go b/server/user_config.go index 10e6e6b9fc..787c6a49e0 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -1,8 +1,11 @@ package server import ( + "encoding/json" "strings" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" ) @@ -66,6 +69,7 @@ type UserConfig struct { GiteaWebhookSecret string `mapstructure:"gitea-webhook-secret"` GiteaPageSize int `mapstructure:"gitea-page-size"` GitlabHostname string `mapstructure:"gitlab-hostname"` + GitlabGroupAllowlist string `mapstructure:"gitlab-group-allowlist"` GitlabToken string `mapstructure:"gitlab-token"` GitlabUser string `mapstructure:"gitlab-user"` GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` @@ -109,7 +113,7 @@ type UserConfig struct { SSLCertFile string `mapstructure:"ssl-cert-file"` SSLKeyFile string `mapstructure:"ssl-key-file"` RestrictFileList bool `mapstructure:"restrict-file-list"` - TFDistribution string `mapstructure:"tf-distribution"` + TFDistribution string `mapstructure:"tf-distribution"` // deprecated in favor of DefaultTFDistribution TFDownload bool `mapstructure:"tf-download"` TFDownloadURL string `mapstructure:"tf-download-url"` TFEHostname string `mapstructure:"tfe-hostname"` @@ -117,8 +121,10 @@ type UserConfig struct { TFEToken string `mapstructure:"tfe-token"` VarFileAllowlist string `mapstructure:"var-file-allowlist"` VCSStatusName string `mapstructure:"vcs-status-name"` + DefaultTFDistribution string `mapstructure:"default-tf-distribution"` DefaultTFVersion string `mapstructure:"default-tf-version"` Webhooks []WebhookConfig `mapstructure:"webhooks" flag:"false"` + WebhookHttpHeaders string `mapstructure:"webhook-http-headers"` WebBasicAuth bool `mapstructure:"web-basic-auth"` WebUsername string `mapstructure:"web-username"` WebPassword string `mapstructure:"web-password"` @@ -151,6 +157,37 @@ func (u UserConfig) ToAllowCommandNames() ([]command.Name, error) { return allowCommands, nil } +// ToWebhookHttpHeaders parses WebhookHttpHeaders into a map of HTTP headers. +func (u UserConfig) ToWebhookHttpHeaders() (map[string][]string, error) { + if u.WebhookHttpHeaders == "" { + return nil, nil + } + + var m map[string]interface{} + err := json.Unmarshal([]byte(u.WebhookHttpHeaders), &m) + if err != nil { + return nil, err + } + headers := make(map[string][]string) + for name, rawValue := range m { + switch val := rawValue.(type) { + case []interface{}: + for _, v := range val { + s, ok := v.(string) + if !ok { + return nil, errors.Errorf("expected string array element, got %T", v) + } + headers[name] = append(headers[name], s) + } + case string: + headers[name] = []string{val} + default: + return nil, errors.Errorf("expected string or array, got %T", val) + } + } + return headers, nil +} + // ToLogLevel returns the LogLevel object corresponding to the user-passed // log level. func (u UserConfig) ToLogLevel() logging.LogLevel { diff --git a/server/user_config_test.go b/server/user_config_test.go index 225049f335..b37f04cf8b 100644 --- a/server/user_config_test.go +++ b/server/user_config_test.go @@ -3,6 +3,8 @@ package server_test import ( "testing" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" @@ -69,6 +71,49 @@ func TestUserConfig_ToAllowCommandNames(t *testing.T) { } } +func TestUserConfig_ToWebhookHttpHeaders(t *testing.T) { + tcs := []struct { + name string + given string + want map[string][]string + err error + }{ + { + name: "empty", + given: "", + want: nil, + }, + { + name: "happy path", + given: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`, + want: map[string][]string{ + "Authorization": {"Bearer some-token"}, + "X-Custom-Header": {"value1", "value2"}, + }, + }, + { + name: "invalid json", + given: `{"X-Custom-Header":true}`, + err: errors.New("expected string or array, got bool"), + }, + { + name: "invalid json array element", + given: `{"X-Custom-Header":[1, 2]}`, + err: errors.New("expected string array element, got float64"), + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + u := server.UserConfig{ + WebhookHttpHeaders: tc.given, + } + got, err := u.ToWebhookHttpHeaders() + Equals(t, tc.want, got) + Equals(t, tc.err, err) + }) + } +} + func TestUserConfig_ToLogLevel(t *testing.T) { cases := []struct { userLvl string diff --git a/testdrive/github.go b/testdrive/github.go index a56d3eee35..4fc8279678 100644 --- a/testdrive/github.go +++ b/testdrive/github.go @@ -18,7 +18,7 @@ import ( "strings" "time" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" ) var githubUsername string @@ -57,13 +57,13 @@ func (g *Client) CheckForkSuccess(ownerName string, forkRepoName string) bool { func (g *Client) CreateWebhook(ownerName string, repoName string, hookURL string) error { contentType := "json" hookConfig := &github.HookConfig{ - ContentType: &contentType, - URL: &hookURL, + ContentType: github.Ptr(contentType), + URL: github.Ptr(hookURL), } atlantisHook := &github.Hook{ Events: []string{"issue_comment", "pull_request", "pull_request_review", "push"}, Config: hookConfig, - Active: github.Bool(true), + Active: github.Ptr(true), } _, _, err := g.client.Repositories.CreateHook(g.ctx, ownerName, repoName, atlantisHook) return err @@ -87,10 +87,10 @@ func (g *Client) CreatePullRequest(ownerName string, repoName string, head strin // If not, create it. newPullRequest := &github.NewPullRequest{ - Title: github.String("Welcome to Atlantis!"), - Head: github.String(head), - Body: github.String(pullRequestBody), - Base: github.String(base), + Title: github.Ptr("Welcome to Atlantis!"), + Head: github.Ptr(head), + Body: github.Ptr(pullRequestBody), + Base: github.Ptr(base), } pull, _, err := g.client.PullRequests.Create(g.ctx, ownerName, repoName, newPullRequest) if err != nil { diff --git a/testdrive/testdrive.go b/testdrive/testdrive.go index 6847540fc0..9f2b61c6c7 100644 --- a/testdrive/testdrive.go +++ b/testdrive/testdrive.go @@ -31,7 +31,7 @@ import ( "time" "github.com/briandowns/spinner" - "github.com/google/go-github/v66/github" + "github.com/google/go-github/v68/github" "github.com/mitchellh/colorstring" "github.com/pkg/errors" ) diff --git a/testdrive/utils.go b/testdrive/utils.go index 872e750d4f..04d8561cee 100644 --- a/testdrive/utils.go +++ b/testdrive/utils.go @@ -35,7 +35,7 @@ import ( ) const hashicorpReleasesURL = "https://releases.hashicorp.com" -const terraformVersion = "1.10.3" // renovate: datasource=github-releases depName=hashicorp/terraform versioning=hashicorp +const terraformVersion = "1.10.5" // renovate: datasource=github-releases depName=hashicorp/terraform versioning=hashicorp const ngrokDownloadURL = "https://bin.equinox.io/c/4VmDzA7iaHb" const ngrokAPIURL = "localhost:41414" // We hope this isn't used. const atlantisPort = 4141 diff --git a/testing/Dockerfile b/testing/Dockerfile index 4442c1fe8b..c997796c20 100644 --- a/testing/Dockerfile +++ b/testing/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23.4@sha256:7ea4c9dcb2b97ff8ee80a67db3d44f98c8ffa0d191399197007d8459c1453041 +FROM golang:1.23.5@sha256:8c10f21bec412f08f73aa7b97ca5ac5f28a39d8a88030ad8a339fd0a781d72b4 RUN apt-get update && apt-get --no-install-recommends -y install unzip \ && apt-get clean \ @@ -6,7 +6,7 @@ RUN apt-get update && apt-get --no-install-recommends -y install unzip \ # Install Terraform # renovate: datasource=github-releases depName=hashicorp/terraform versioning=hashicorp -ENV TERRAFORM_VERSION=1.10.3 +ENV TERRAFORM_VERSION=1.10.5 RUN case $(uname -m) in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64|armv7l) ARCH="arm64" ;; esac && \ wget -nv -O terraform.zip https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${ARCH}.zip && \ mkdir -p /usr/local/bin/tf/versions/${TERRAFORM_VERSION} && \